diff --git a/.codespellrc b/.codespellrc index a40dd494..f0a31988 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,2 +1,3 @@ [codespell] -skip = .git,countries.json +skip = .git,countries.json,libs.versions.toml +ignore-words-list = OptIn,expresso,statics \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 499d670b..266baaa6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,5 +16,8 @@ ij_smart_tabs = false ij_visual_guides = none ij_wrap_on_typing = false -[*.{kt,kts}] -disabled_rules = import-ordering \ No newline at end of file +[{*.kt,*.kts}] +ij_kotlin_allow_trailing_comma = false +ij_kotlin_allow_trailing_comma_on_call_site = false +ij_kotlin_packages_to_use_import_on_demand = unset +ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ \ No newline at end of file diff --git a/.kotlin/sessions/kotlin-compiler-2172790541716978083.salive b/.kotlin/sessions/kotlin-compiler-2172790541716978083.salive new file mode 100644 index 00000000..e69de29b diff --git a/.kotlin/sessions/kotlin-compiler-7060104201537045237.salive b/.kotlin/sessions/kotlin-compiler-7060104201537045237.salive new file mode 100644 index 00000000..e69de29b diff --git a/build-config/klint.gradle.kts b/build-config/klint.gradle.kts index 49537bee..3d941e50 100644 --- a/build-config/klint.gradle.kts +++ b/build-config/klint.gradle.kts @@ -1,7 +1,7 @@ val ktlint by configurations.creating dependencies { - ktlint("com.pinterest:ktlint:0.34.2") + ktlint("com.pinterest:ktlint:0.48.2") } tasks { @@ -19,8 +19,9 @@ tasks { val ktlintFormat by creating(JavaExec::class) { group = "formatting" description = "Fix Kotlin code style deviations." - mainClass.set("com.pinterest.ktlint.Main") classpath = configurations["ktlint"] - args("-F", "src/**/*.kt") + jvmArgs = listOf("--add-opens=java.base/java.lang=ALL-UNNAMED") + setProperty("mainClass", "com.pinterest.ktlint.Main") + args = listOf("-F", "src/**/*.kt") } } \ No newline at end of file diff --git a/falu-core/build.gradle.kts b/falu-core/build.gradle.kts index 7aa734e9..3e2a5aa1 100644 --- a/falu-core/build.gradle.kts +++ b/falu-core/build.gradle.kts @@ -11,7 +11,7 @@ apply { apply(from = "${rootDir}/build-config/klint.gradle.kts") android { - compileSdk = 34 + compileSdk = 35 namespace = project.properties["FALU_SDK_NAMESPACE"].toString() val publishVersionCode: String by project.extra diff --git a/falu-core/src/main/java/io/falu/core/ApiKeyValidator.kt b/falu-core/src/main/java/io/falu/core/ApiKeyValidator.kt index c658ad4b..f6bb8b11 100644 --- a/falu-core/src/main/java/io/falu/core/ApiKeyValidator.kt +++ b/falu-core/src/main/java/io/falu/core/ApiKeyValidator.kt @@ -7,15 +7,13 @@ class ApiKeyValidator { fun requireValid(apiKey: String?): String { require(!apiKey.isNullOrBlank()) { - "Invalid Publishable Key: " + - "You must use a valid FALU API key to make a FALU API request. " + - "For more info, see https://falu.io" + "Invalid Publishable Key: You must use a valid FALU API key to make a FALU API request. " + + "For more info, see https://falu.io" } require(!apiKey.startsWith("sk_")) { - "Invalid Publishable Key: " + - "You are using a secret key instead of a publishable one. " + - "For more info, see https://falu.io" + "Invalid Publishable Key: You are using a secret key instead of a publishable one. " + + "For more info, see https://falu.io" } return apiKey diff --git a/falu-core/src/main/java/io/falu/core/ApiVersionInterceptor.kt b/falu-core/src/main/java/io/falu/core/ApiVersionInterceptor.kt index 3a07c858..a24775ee 100644 --- a/falu-core/src/main/java/io/falu/core/ApiVersionInterceptor.kt +++ b/falu-core/src/main/java/io/falu/core/ApiVersionInterceptor.kt @@ -14,7 +14,6 @@ class ApiVersionInterceptor(private val code: String) : Interceptor { .request() .newBuilder() .header("X-Falu-Version", code) - .build() return chain.proceed(request) diff --git a/falu-core/src/main/java/io/falu/core/AppDetailsInterceptor.kt b/falu-core/src/main/java/io/falu/core/AppDetailsInterceptor.kt index d7db0b1b..050c3fb4 100644 --- a/falu-core/src/main/java/io/falu/core/AppDetailsInterceptor.kt +++ b/falu-core/src/main/java/io/falu/core/AppDetailsInterceptor.kt @@ -38,7 +38,7 @@ class AppDetailsInterceptor(private val context: Context) : Interceptor { val versionRelease = Build.VERSION.RELEASE return "falu-android/${BuildConfig.FALU_VERSION_NAME} (Android $versionRelease; $manufacturer $model) " + - "$packageName/${context.appVersionName}" + "$packageName/${context.appVersionName}" } private val packageName: String diff --git a/falu-core/src/main/java/io/falu/core/exceptions/ApiConnectionException.kt b/falu-core/src/main/java/io/falu/core/exceptions/ApiConnectionException.kt index 1015ef6a..3a9020b8 100644 --- a/falu-core/src/main/java/io/falu/core/exceptions/ApiConnectionException.kt +++ b/falu-core/src/main/java/io/falu/core/exceptions/ApiConnectionException.kt @@ -20,8 +20,8 @@ class ApiConnectionException( ).joinToString(" ") return ApiConnectionException( "IOException during API request to $displayUrl: ${e.message}. " + - "Please check your internet connection and try again. " + - "If this problem persists, let us know at support@falu.io.", + "Please check your internet connection and try again. " + + "If this problem persists, let us know at support@falu.io.", e ) } diff --git a/falu-core/src/main/java/io/falu/core/exceptions/AuthenticationException.kt b/falu-core/src/main/java/io/falu/core/exceptions/AuthenticationException.kt index da567093..9370a56c 100644 --- a/falu-core/src/main/java/io/falu/core/exceptions/AuthenticationException.kt +++ b/falu-core/src/main/java/io/falu/core/exceptions/AuthenticationException.kt @@ -8,7 +8,8 @@ import java.net.HttpURLConnection * No valid API key provided. */ class AuthenticationException -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor(problem: HttpApiResponseProblem) : +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +constructor(problem: HttpApiResponseProblem) : FaluException( problem, HttpURLConnection.HTTP_UNAUTHORIZED diff --git a/falu-core/src/main/java/io/falu/core/exceptions/FaluException.kt b/falu-core/src/main/java/io/falu/core/exceptions/FaluException.kt index 17a01f87..d18b86ca 100644 --- a/falu-core/src/main/java/io/falu/core/exceptions/FaluException.kt +++ b/falu-core/src/main/java/io/falu/core/exceptions/FaluException.kt @@ -22,10 +22,8 @@ abstract class FaluException( } private fun typedEquals(ex: FaluException): Boolean { - return problem == ex.problem && - statusCode == ex.statusCode && - errorCode == ex.errorCode && - message == ex.message + return problem == ex.problem && statusCode == ex.statusCode && errorCode == ex.errorCode && + message == ex.message } override fun equals(other: Any?): Boolean { diff --git a/falu-core/src/test/java/io/falu/core/NetworkRetriesInterceptorTest.kt b/falu-core/src/test/java/io/falu/core/NetworkRetriesInterceptorTest.kt index dfee55b5..c7c291ee 100644 --- a/falu-core/src/test/java/io/falu/core/NetworkRetriesInterceptorTest.kt +++ b/falu-core/src/test/java/io/falu/core/NetworkRetriesInterceptorTest.kt @@ -3,15 +3,15 @@ package io.falu.core import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response +import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito import org.mockito.Mockito.`when` +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import kotlin.test.BeforeTest -import org.junit.Test -import org.mockito.Mockito import kotlin.test.assertEquals -import org.mockito.kotlin.times -import org.mockito.kotlin.verify @RunWith(RobolectricTestRunner::class) class NetworkRetriesInterceptorTest { diff --git a/falu/build.gradle.kts b/falu/build.gradle.kts index d291cc3e..c8f12f35 100644 --- a/falu/build.gradle.kts +++ b/falu/build.gradle.kts @@ -7,7 +7,7 @@ id("com.android.library") apply(from = "${rootDir}/build-config/klint.gradle.kts") android { - compileSdk = 34 + compileSdk = 35 namespace = project.properties["FALU_SDK_NAMESPACE"].toString() defaultConfig { diff --git a/falu/src/main/java/io/falu/android/models/payments/Payment.kt b/falu/src/main/java/io/falu/android/models/payments/Payment.kt index b34bcec7..37218c37 100644 --- a/falu/src/main/java/io/falu/android/models/payments/Payment.kt +++ b/falu/src/main/java/io/falu/android/models/payments/Payment.kt @@ -2,9 +2,9 @@ package io.falu.android.models.payments import com.google.gson.annotations.JsonAdapter import io.falu.core.models.FaluModel -import java.util.Date import kotlinx.parcelize.Parcelize import software.tingle.api.adapters.ISO8601DateAdapter +import java.util.Date /** * [The payment object](https://falu.io) diff --git a/falu/src/main/java/io/falu/android/models/payments/PaymentFailure.kt b/falu/src/main/java/io/falu/android/models/payments/PaymentFailure.kt index daf0ba10..5141a85a 100644 --- a/falu/src/main/java/io/falu/android/models/payments/PaymentFailure.kt +++ b/falu/src/main/java/io/falu/android/models/payments/PaymentFailure.kt @@ -1,8 +1,8 @@ package io.falu.android.models.payments import android.os.Parcelable -import java.util.Date import kotlinx.parcelize.Parcelize +import java.util.Date /** * [The Payment Failure object](https://falu.io) diff --git a/falu/src/main/java/io/falu/android/networking/BaseApiRepository.kt b/falu/src/main/java/io/falu/android/networking/BaseApiRepository.kt index ec96f063..4e427a02 100644 --- a/falu/src/main/java/io/falu/android/networking/BaseApiRepository.kt +++ b/falu/src/main/java/io/falu/android/networking/BaseApiRepository.kt @@ -3,12 +3,12 @@ package io.falu.android.networking import android.content.Context import io.falu.core.exceptions.ApiException import io.falu.core.exceptions.FaluException -import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import software.tingle.api.ResourceResponse +import kotlin.coroutines.CoroutineContext /** * A base class for Falu-related API requests. diff --git a/falu/src/test/java/io/falu/android/ApiKeyValidatorTests.kt b/falu/src/test/java/io/falu/android/ApiKeyValidatorTests.kt index e2f1b986..75faf3ef 100644 --- a/falu/src/test/java/io/falu/android/ApiKeyValidatorTests.kt +++ b/falu/src/test/java/io/falu/android/ApiKeyValidatorTests.kt @@ -1,9 +1,9 @@ package io.falu.android import io.falu.core.ApiKeyValidator -import kotlin.test.assertFailsWith import org.junit.Assert.assertEquals import org.junit.Test +import kotlin.test.assertFailsWith class ApiKeyValidatorTests { diff --git a/falu/src/test/java/io/falu/android/FaluEndToEndTest.kt b/falu/src/test/java/io/falu/android/FaluEndToEndTest.kt index 516b4457..90e18b86 100644 --- a/falu/src/test/java/io/falu/android/FaluEndToEndTest.kt +++ b/falu/src/test/java/io/falu/android/FaluEndToEndTest.kt @@ -8,13 +8,13 @@ import io.falu.android.models.payments.Payment import io.falu.android.models.payments.PaymentRequest import io.falu.android.networking.FaluRepository import io.falu.core.ApiResultCallback -import java.util.Date -import kotlin.test.Test import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.util.Date +import kotlin.test.Test @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.O_MR1]) @@ -45,7 +45,6 @@ class FaluEndToEndTest { @Test fun `test mpesa payment works`() { - val mpesa = MpesaPaymentRequest( phone = "+254712345678", reference = "254712345678", diff --git a/falu/src/test/java/io/falu/android/MoneyPatternTests.kt b/falu/src/test/java/io/falu/android/MoneyPatternTests.kt index 3bd09d19..759fc501 100644 --- a/falu/src/test/java/io/falu/android/MoneyPatternTests.kt +++ b/falu/src/test/java/io/falu/android/MoneyPatternTests.kt @@ -1,9 +1,9 @@ package io.falu.android import io.falu.android.models.payments.Money +import org.junit.Test import java.util.Currency import kotlin.test.assertEquals -import org.junit.Test class MoneyPatternTests { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 425098cd..51e35b90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,9 +28,16 @@ material = "1.12.0" joda = "2.13.0" nexus = "2.0.0" coil = "2.7.0" +compose-bom = "2024.09.02" +accompanist = "0.34.0" +runtime-livedata = "1.7.2" +ui-test-junit4-android = "1.7.2" [libraries] androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } kotlin = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } abstractions = { module = "software.tingle:abstractions", version.ref = "abstractions" } junit = { module = "junit:junit", version.ref = "junit" } @@ -73,11 +80,24 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android androidx-browser = { group = "androidx.browser", name = "browser", version = "1.8.0" } coil-ktx = { module = "io.coil-kt:coil", version.ref = "coil" } coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } + +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +compose-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +runtime = { module = "androidx.compose.runtime:runtime" } +ui-tests = { module = "androidx.compose.ui:ui-test-junit4" } +ui-tests-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +material3 = { module = "androidx.compose.material3:material3" } +accompanist = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } +compose-activity = { module = "androidx.activity:activity-compose" } +compose-navigation = { module = "androidx.navigation:navigation-compose" } tensorflow-lite = { module = "org.tensorflow:tensorflow-lite", version.ref = "tensorflow-lite" } tensorflow-support = { module = "org.tensorflow:tensorflow-lite-support", version.ref = "tensorflow-support" } joda = { module = "joda-time:joda-time", version.ref = "joda" } +androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtime-livedata" } +androidx-ui-test-junit4-android = { group = "androidx.compose.ui", name = "ui-test-junit4-android", version.ref = "ui-test-junit4-android" } [bundles] mokito = ["mokito-core", "mokito-inline"] @@ -85,7 +105,8 @@ kotlin-test = ["kotlin-test-junit", "kotlin-test-annotations"] androidx-lifecycle = ["androidx-livedata", "androidx-viewmodel", "androidx-runtime"] camera = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-lifecycle", "androidx-camera-video", "androidx-camera-extensions", "androidx-camera-view"] navigation = ["androidx-navigation-fragment", "androidx-navigation-ui"] -coil = ["coil-ktx", "coil-svg"] +coil = ["coil-ktx", "coil-svg", "coil-compose"] [plugins] -nexus = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus" } \ No newline at end of file +nexus = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file diff --git a/identity-sample/build.gradle.kts b/identity-sample/build.gradle.kts index 1561b406..f8cc37df 100644 --- a/identity-sample/build.gradle.kts +++ b/identity-sample/build.gradle.kts @@ -6,13 +6,13 @@ plugins { apply(from = "${rootDir}/build-config/klint.gradle.kts") android { - compileSdk = 34 + compileSdk = 35 namespace = project.properties["FALU_SDK_NAMESPACE"].toString() defaultConfig { applicationId = "io.falu.identity.sample" minSdk = 26 - targetSdk = 34 + targetSdk = 35 versionCode = 1 versionName = "1.0" diff --git a/identity-sample/src/main/java/io/falu/identity/VerificationViewModel.kt b/identity-sample/src/main/java/io/falu/identity/VerificationViewModel.kt index e9fb9e67..438339b9 100644 --- a/identity-sample/src/main/java/io/falu/identity/VerificationViewModel.kt +++ b/identity-sample/src/main/java/io/falu/identity/VerificationViewModel.kt @@ -32,16 +32,18 @@ class VerificationViewModel(application: Application) : AndroidViewModel(applica allowIdNumberVerification: Boolean, allowTaxPin: Boolean ) = liveData { - val request = IdentityVerificationCreationRequest( options = IdentityVerificationOptions( allowUploads = allowUploads, - document = if (allowDrivingLicense || allowPassport || allowIdentityCard) + document = if (allowDrivingLicense || allowPassport || allowIdentityCard) { generateDocumentOptions( allowDrivingLicense, allowPassport, allowIdentityCard - ) else null, + ) + } else { + null + }, selfie = if (allowDocumentSelfie) IdentityVerificationOptionsForSelfie() else null, idNumber = if (allowIdNumberVerification) IdentityVerificationOptionsForIdNumber() else null, tax = if (allowTaxPin) IdentityVerificationOptionsForTax(allowed = mutableListOf("ken_pin")) else null @@ -92,7 +94,7 @@ class VerificationViewModel(application: Application) : AndroidViewModel(applica class ApiClient : AbstractHttpApiClient(EmptyAuthenticationProvider()) { suspend fun createIdentityVerification(request: IdentityVerificationCreationRequest): - ResourceResponse { + ResourceResponse { val builder = Request.Builder() .url("https://identity-verification.hst-smpls.falu.io/identity/create-verification") .post(makeJson(request).toRequestBody(MEDIA_TYPE_JSON)) diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index 21432f98..d4781475 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -2,12 +2,13 @@ plugins { id("com.android.library") kotlin("android") id("kotlin-parcelize") + alias(libs.plugins.compose.compiler) } apply(from = "${rootDir}/build-config/klint.gradle.kts") android { - compileSdk = 34 + compileSdk = 35 namespace = project.properties["FALU_SDK_NAMESPACE"].toString() defaultConfig { @@ -46,6 +47,7 @@ android { buildFeatures { viewBinding = true buildConfig = true + compose = true } lint.enable += "Interoperability" @@ -73,6 +75,18 @@ dependencies { implementation(libs.constraint) implementation(libs.androidx.appcompat) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.preview) + implementation(libs.material3) + implementation(libs.compose.activity) + implementation(libs.compose.navigation) + implementation(libs.runtime) + implementation(libs.androidx.ui.graphics) + implementation(libs.accompanist) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.ui.test.junit4.android) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.tensorflow.lite) implementation(libs.tensorflow.support) @@ -85,6 +99,7 @@ dependencies { implementation(libs.material) implementation(libs.joda) implementation(libs.bundles.coil) + implementation(libs.androidx.browser) testImplementation(libs.junit) testImplementation(libs.bundles.mokito) @@ -98,7 +113,9 @@ dependencies { testImplementation(libs.androidx.espresso.core) testImplementation(libs.androidx.fragment.testing) testImplementation(libs.androidx.core.ktx) - implementation(libs.androidx.browser) + testImplementation(libs.ui.tests) + testImplementation(libs.ui.tests.manifest) + testImplementation(libs.androidx.ui.tooling) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.runner) diff --git a/identity/src/main/java/io/falu/identity/FallbackUrlCallback.kt b/identity/src/main/java/io/falu/identity/FallbackUrlCallback.kt new file mode 100644 index 00000000..43ca92f1 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/FallbackUrlCallback.kt @@ -0,0 +1,5 @@ +package io.falu.identity + +internal interface FallbackUrlCallback { + fun launchFallbackUrl(url: String) +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/IdentityBackPressHandler.kt b/identity/src/main/java/io/falu/identity/IdentityBackPressHandler.kt new file mode 100644 index 00000000..f3523b75 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/IdentityBackPressHandler.kt @@ -0,0 +1,23 @@ +package io.falu.identity + +import androidx.activity.OnBackPressedCallback +import androidx.navigation.NavController +import io.falu.identity.navigation.InitialDestination +import io.falu.identity.navigation.WelcomeDestination + +internal class IdentityBackPressHandler( + private val navController: NavController, + private val identityVerificationCallback: IdentityVerificationResultCallback +) : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (navController.previousBackStackEntry?.destination?.route == InitialDestination.ROUTE.route || + navController.previousBackStackEntry?.destination?.route == WelcomeDestination.ROUTE.route + ) { + finishWithCancelResult(identityVerificationCallback) + } + } + + private fun finishWithCancelResult(callback: IdentityVerificationResultCallback) { + callback.onFinishWithResult(IdentityVerificationResult.Canceled) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/IdentityVerificationActivity.kt b/identity/src/main/java/io/falu/identity/IdentityVerificationActivity.kt index 9056796d..05511091 100644 --- a/identity/src/main/java/io/falu/identity/IdentityVerificationActivity.kt +++ b/identity/src/main/java/io/falu/identity/IdentityVerificationActivity.kt @@ -4,22 +4,27 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Bundle -import android.view.View +import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.browser.customtabs.CustomTabsIntent -import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.NavController import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder import io.falu.identity.api.IdentityVerificationApiClient -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.api.models.verification.VerificationStatus -import io.falu.identity.databinding.ActivityIdentityVerificationBinding +import io.falu.identity.navigation.IdentityNavigationGraph +import io.falu.identity.ui.theme.IdentityTheme import io.falu.identity.utils.FileUtils - -internal class IdentityVerificationActivity : AppCompatActivity(), +import io.falu.identity.utils.IdentityImageHandler +import io.falu.identity.viewModel.DocumentScanViewModel +import io.falu.identity.viewModel.FaceScanViewModel +import io.falu.identity.viewModel.IdentityVerificationViewModel + +internal class IdentityVerificationActivity : + AppCompatActivity(), + FallbackUrlCallback, IdentityVerificationResultCallback { @VisibleForTesting @@ -29,16 +34,26 @@ internal class IdentityVerificationActivity : AppCompatActivity(), { apiClient }, { analyticsRequestBuilder }, { fileUtils }, + { IdentityImageHandler() }, { contractArgs } ) + @VisibleForTesting + internal lateinit var navController: NavController + + private lateinit var onBackPressedCallback: IdentityBackPressHandler + private val verificationViewModel: IdentityVerificationViewModel by viewModels { factory } - private val binding by lazy { - ActivityIdentityVerificationBinding.inflate(layoutInflater) - } + private val documentScanViewModel: DocumentScanViewModel by viewModels { documentScanViewModelFactory } + private val documentScanViewModelFactory = + DocumentScanViewModel.factoryProvider(this) { verificationViewModel.modelPerformanceMonitor } + + private val faceScanViewModel: FaceScanViewModel by viewModels { faceScanViewModelFactory } + private val faceScanViewModelFactory = + FaceScanViewModel.factoryProvider(this) { verificationViewModel.modelPerformanceMonitor } private val contractArgs: ContractArgs by lazy { requireNotNull(ContractArgs.getFromIntent(intent)) { @@ -51,8 +66,12 @@ internal class IdentityVerificationActivity : AppCompatActivity(), } private val apiClient: IdentityVerificationApiClient by lazy { - IdentityVerificationApiClient(this, contractArgs.temporaryKey, contractArgs.maxNetworkRetries, - BuildConfig.DEBUG) + IdentityVerificationApiClient( + this, + contractArgs.temporaryKey, + contractArgs.maxNetworkRetries, + BuildConfig.DEBUG + ) } private val analyticsRequestBuilder: IdentityAnalyticsRequestBuilder by lazy { @@ -62,19 +81,30 @@ internal class IdentityVerificationActivity : AppCompatActivity(), private lateinit var launchFallbackUrl: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - supportFragmentManager.fragmentFactory = - IdentityVerificationFragmentFactory(this, factory) + supportActionBar?.hide() - super.onCreate(savedInstanceState) - setContentView(binding.root) - setNavigationController() - setupFallbackLauncher() + verificationViewModel.registerActivityResultCaller(this) + + setContent { + IdentityTheme { + IdentityNavigationGraph( + identityViewModel = verificationViewModel, + documentScanViewModel = documentScanViewModel, + faceScanViewModel = faceScanViewModel, + contractArgs = contractArgs, + fallbackUrlCallback = this, + verificationResultCallback = this + ) { + this.navController = it + onBackPressedCallback = IdentityBackPressHandler(navController, this) + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + } + } - verificationViewModel.loadUriToImageView( - contractArgs.workspaceLogo, - binding.ivIdentityVerification - ) + setupFallbackLauncher() verificationViewModel.fetchVerification(onFailure = { finishWithVerificationResult(IdentityVerificationResult.Failed(it)) @@ -84,18 +114,12 @@ internal class IdentityVerificationActivity : AppCompatActivity(), verificationViewModel.observeForVerificationResults( this, onSuccess = { - if (savedInstanceState?.getBoolean(KEY_OPENED, false) != true) { verificationViewModel.reportTelemetry(verificationViewModel.analyticsRequestBuilder.viewOpened()) } - - if (!it.supported) { - launchFallbackUrl(it.url.orEmpty()) - } else { - onVerificationSuccessful(it) - } }, - onError = { onVerificationFailure(false, it) }) + onError = { onVerificationFailure(false, it) } + ) } override fun onSaveInstanceState(outState: Bundle) { @@ -103,26 +127,6 @@ internal class IdentityVerificationActivity : AppCompatActivity(), outState.putBoolean(KEY_OPENED, true) } - private fun onVerificationSuccessful(verification: Verification) { - binding.tvWorkspaceName.text = verification.workspace.name - binding.viewLive.visibility = if (verification.live) View.VISIBLE else View.GONE - binding.tvSupport.visibility = if (verification.support != null) View.VISIBLE else View.GONE - binding.viewSandbox.viewSandbox.visibility = - if (verification.live) View.GONE else View.VISIBLE - - when (verification.status) { - VerificationStatus.INPUT_REQUIRED -> { - } - - VerificationStatus.PROCESSING, - VerificationStatus.VERIFIED -> onFinishWithResult( - IdentityVerificationResult.Succeeded - ) - - VerificationStatus.CANCELLED -> onFinishWithResult(IdentityVerificationResult.Canceled) - } - } - private fun onVerificationFailure(fromFallbackUrl: Boolean, throwable: Throwable?) { verificationViewModel.reportTelemetry( verificationViewModel.analyticsRequestBuilder.verificationFailed( @@ -133,7 +137,6 @@ internal class IdentityVerificationActivity : AppCompatActivity(), } private fun finishWithVerificationResult(result: IdentityVerificationResult) { - verificationViewModel.reportTelemetry( verificationViewModel.analyticsRequestBuilder.viewClosed(result.javaClass.name) ) @@ -150,7 +153,8 @@ internal class IdentityVerificationActivity : AppCompatActivity(), verificationViewModel.fetchVerification { finishWithVerificationResult(IdentityVerificationResult.Failed(it)) } - verificationViewModel.observeForVerificationResults(this, + verificationViewModel.observeForVerificationResults( + this, onSuccess = { if (it.submitted) { finishWithVerificationResult(IdentityVerificationResult.Succeeded) @@ -168,7 +172,7 @@ internal class IdentityVerificationActivity : AppCompatActivity(), } } - private fun launchFallbackUrl(url: String) { + override fun launchFallbackUrl(url: String) { val customTabsIntent = CustomTabsIntent.Builder() .build() customTabsIntent.intent.data = Uri.parse(url) @@ -179,21 +183,6 @@ internal class IdentityVerificationActivity : AppCompatActivity(), finishWithVerificationResult(result) } - private fun setNavigationController() { - // - supportActionBar?.hide() - - // - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment - val navController = navHostFragment.navController - - // - binding.tvSupport.setOnClickListener { - navController.navigate(R.id.action_global_fragment_support) - } - } - companion object { private const val KEY_OPENED = ":opened" } diff --git a/identity/src/main/java/io/falu/identity/IdentityVerificationFragmentFactory.kt b/identity/src/main/java/io/falu/identity/IdentityVerificationFragmentFactory.kt deleted file mode 100644 index 1976a355..00000000 --- a/identity/src/main/java/io/falu/identity/IdentityVerificationFragmentFactory.kt +++ /dev/null @@ -1,47 +0,0 @@ -package io.falu.identity - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentFactory -import androidx.lifecycle.ViewModelProvider -import io.falu.identity.capture.manual.ManualCaptureFragment -import io.falu.identity.capture.scan.ScanCaptureFragment -import io.falu.identity.capture.scan.ScanCaptureSideFragment -import io.falu.identity.capture.upload.UploadCaptureFragment -import io.falu.identity.confirmation.ConfirmationFragment -import io.falu.identity.documents.DocumentCaptureMethodsFragment -import io.falu.identity.documents.DocumentSelectionFragment -import io.falu.identity.error.ErrorFragment -import io.falu.identity.error.ScanCaptureErrorFragment -import io.falu.identity.error.SelfieCaptureErrorFragment -import io.falu.identity.selfie.SelfieFragment -import io.falu.identity.support.SupportFragment -import io.falu.identity.verification.IdentificationVerificationFragment -import io.falu.identity.verification.TaxPinVerificationFragment -import io.falu.identity.welcome.WelcomeFragment - -internal class IdentityVerificationFragmentFactory( - private val callback: IdentityVerificationResultCallback, - private val factory: ViewModelProvider.Factory -) : FragmentFactory() { - - override fun instantiate(classLoader: ClassLoader, className: String): Fragment { - return when (className) { - WelcomeFragment::class.java.name -> WelcomeFragment(factory, callback) - DocumentSelectionFragment::class.java.name -> DocumentSelectionFragment(factory) - DocumentCaptureMethodsFragment::class.java.name -> DocumentCaptureMethodsFragment(factory) - ManualCaptureFragment::class.java.name -> ManualCaptureFragment(factory) - ScanCaptureFragment::class.java.name -> ScanCaptureFragment(factory) - ScanCaptureSideFragment::class.java.name -> ScanCaptureSideFragment(factory) - UploadCaptureFragment::class.java.name -> UploadCaptureFragment(factory) - ConfirmationFragment::class.java.name -> ConfirmationFragment(factory, callback) - SupportFragment::class.java.name -> SupportFragment(factory) - SelfieFragment::class.java.name -> SelfieFragment(factory) - ErrorFragment::class.java.name -> ErrorFragment(factory) - SelfieCaptureErrorFragment::class.java.name -> SelfieCaptureErrorFragment(factory) - ScanCaptureErrorFragment::class.java.name -> ScanCaptureErrorFragment(factory) - IdentificationVerificationFragment::class.java.name -> IdentificationVerificationFragment(factory) - TaxPinVerificationFragment::class.java.name -> TaxPinVerificationFragment(factory) - else -> super.instantiate(classLoader, className) - } - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/scan/DocumentDispositionMachine.kt b/identity/src/main/java/io/falu/identity/ai/DocumentDispositionMachine.kt similarity index 94% rename from identity/src/main/java/io/falu/identity/capture/scan/DocumentDispositionMachine.kt rename to identity/src/main/java/io/falu/identity/ai/DocumentDispositionMachine.kt index 62725013..367ef70c 100644 --- a/identity/src/main/java/io/falu/identity/capture/scan/DocumentDispositionMachine.kt +++ b/identity/src/main/java/io/falu/identity/ai/DocumentDispositionMachine.kt @@ -1,11 +1,6 @@ -package io.falu.identity.capture.scan +package io.falu.identity.ai import android.util.Log -import io.falu.identity.ai.BoundingBox -import io.falu.identity.ai.DetectionOutput -import io.falu.identity.ai.DocumentDetectionOutput -import io.falu.identity.ai.DocumentOption -import io.falu.identity.ai.calculateIOU import io.falu.identity.scan.ScanDisposition import io.falu.identity.scan.ScanDispositionDetector import io.falu.identity.utils.matches @@ -71,7 +66,7 @@ internal class DocumentDispositionMachine( Log.d( TAG, "Score (${output.score}) for (${output.option}) " + - "doesn't meet the required threshold." + "doesn't meet the required threshold." ) state.reached = DateTime.now() state diff --git a/identity/src/main/java/io/falu/identity/ai/DocumentEngine.kt b/identity/src/main/java/io/falu/identity/ai/DocumentEngine.kt index d0102b31..ff1d7a80 100644 --- a/identity/src/main/java/io/falu/identity/ai/DocumentEngine.kt +++ b/identity/src/main/java/io/falu/identity/ai/DocumentEngine.kt @@ -68,7 +68,8 @@ internal class DocumentEngine( val detectionsBuffer = TensorBuffer.createFixedSize(detectionsTensorShape, DataType.FLOAT32) interpreter.runForMultipleInputsOutputs( - arrayOf(tensorImage.buffer), mapOf( + arrayOf(tensorImage.buffer), + mapOf( 0 to documentOptionScoresBuffer.buffer, 1 to boundingBoxesBuffer.buffer, 2 to detectionsBuffer.buffer, diff --git a/identity/src/main/java/io/falu/identity/ai/FaceDetectionAnalyzer.kt b/identity/src/main/java/io/falu/identity/ai/FaceDetectionAnalyzer.kt index 5cb3a8f8..47c44fa3 100644 --- a/identity/src/main/java/io/falu/identity/ai/FaceDetectionAnalyzer.kt +++ b/identity/src/main/java/io/falu/identity/ai/FaceDetectionAnalyzer.kt @@ -64,7 +64,7 @@ internal class FaceDetectionAnalyzer internal constructor( tensorImage = processor.process(tensorImage) preprocessingMonitor.monitor( stats = "width: ${tensorImage.width}; height: ${tensorImage.height}; " + - "rotation: ${image.imageInfo.rotationDegrees}" + "rotation: ${image.imageInfo.rotationDegrees}" ) val inferenceMonitor = performanceMonitor.monitorInference() diff --git a/identity/src/main/java/io/falu/identity/selfie/FaceDispositionMachine.kt b/identity/src/main/java/io/falu/identity/ai/FaceDispositionMachine.kt similarity index 95% rename from identity/src/main/java/io/falu/identity/selfie/FaceDispositionMachine.kt rename to identity/src/main/java/io/falu/identity/ai/FaceDispositionMachine.kt index 58f7bc44..040160ad 100644 --- a/identity/src/main/java/io/falu/identity/selfie/FaceDispositionMachine.kt +++ b/identity/src/main/java/io/falu/identity/ai/FaceDispositionMachine.kt @@ -1,12 +1,8 @@ -package io.falu.identity.selfie +package io.falu.identity.ai import android.util.Log -import io.falu.identity.ai.BoundingBox -import io.falu.identity.ai.DetectionOutput -import io.falu.identity.ai.FaceDetectionOutput -import io.falu.identity.ai.calculateIOU -import io.falu.identity.scan.ScanDispositionDetector import io.falu.identity.scan.ScanDisposition +import io.falu.identity.scan.ScanDispositionDetector import org.joda.time.DateTime import org.joda.time.Seconds diff --git a/identity/src/main/java/io/falu/identity/ai/Model.kt b/identity/src/main/java/io/falu/identity/ai/Model.kt index 75620389..61d5b226 100644 --- a/identity/src/main/java/io/falu/identity/ai/Model.kt +++ b/identity/src/main/java/io/falu/identity/ai/Model.kt @@ -16,7 +16,7 @@ internal enum class DocumentOption { ID_BACK, ID_FRONT, PASSPORT, - INVALID; + INVALID } /** diff --git a/identity/src/main/java/io/falu/identity/analytics/IdentityAnalyticsRequestBuilder.kt b/identity/src/main/java/io/falu/identity/analytics/IdentityAnalyticsRequestBuilder.kt index 7fa7821e..e4dd8be6 100644 --- a/identity/src/main/java/io/falu/identity/analytics/IdentityAnalyticsRequestBuilder.kt +++ b/identity/src/main/java/io/falu/identity/analytics/IdentityAnalyticsRequestBuilder.kt @@ -27,7 +27,8 @@ internal class IdentityAnalyticsRequestBuilder( ) fun viewClosed(verificationResult: String) = createRequest( - EVENT_VERIFICATION_VIEW_CLOSED, makeEventParameters( + EVENT_VERIFICATION_VIEW_CLOSED, + makeEventParameters( KEY_VIEW_RESULT to verificationResult ) ) @@ -41,7 +42,8 @@ internal class IdentityAnalyticsRequestBuilder( selfieModelScore: Float? = null, selfie: Boolean? = null ) = createRequest( - EVENT_VERIFICATION_SUCCESSFUL, makeEventParameters( + EVENT_VERIFICATION_SUCCESSFUL, + makeEventParameters( KEY_FROM_FALLBACK_URL to fromFallbackUrl.toString(), KEY_DOCUMENT_SCAN_TYPE to scanType?.name, KEY_SELFIE_REQUIRED to selfie, @@ -58,7 +60,8 @@ internal class IdentityAnalyticsRequestBuilder( selfie: Boolean? = null, previousScreenName: String? = null ) = createRequest( - EVENT_VERIFICATION_CANCELED, makeEventParameters( + EVENT_VERIFICATION_CANCELED, + makeEventParameters( KEY_FROM_FALLBACK_URL to fromFallbackUrl, KEY_DOCUMENT_SCAN_TYPE to scanType?.name, KEY_SELFIE_REQUIRED to selfie, @@ -72,7 +75,8 @@ internal class IdentityAnalyticsRequestBuilder( selfie: Boolean? = null, throwable: Throwable? ) = createRequest( - EVENT_VERIFICATION_FAILED, makeEventParameters( + EVENT_VERIFICATION_FAILED, + makeEventParameters( KEY_FROM_FALLBACK_URL to fromFallbackUrl, KEY_DOCUMENT_SCAN_TYPE to scanType?.name, KEY_SELFIE_REQUIRED to selfie, @@ -84,7 +88,8 @@ internal class IdentityAnalyticsRequestBuilder( ) fun documentScanTimeOut(scanType: ScanDisposition.DocumentScanType) = createRequest( - EVENT_IDENTITY_DOCUMENT_TIMEOUT, makeEventParameters(KEY_DOCUMENT_SCAN_TYPE to scanType.name) + EVENT_IDENTITY_DOCUMENT_TIMEOUT, + makeEventParameters(KEY_DOCUMENT_SCAN_TYPE to scanType.name) ) fun cameraPermissionDenied( @@ -106,7 +111,8 @@ internal class IdentityAnalyticsRequestBuilder( ) fun cameraInfo(rotation: Int?) = createRequest( - EVENT_CAMERA_INFO, makeEventParameters( + EVENT_CAMERA_INFO, + makeEventParameters( KEY_CAMERA_ROTATION to rotation.toString() ) ) @@ -182,5 +188,8 @@ internal class IdentityAnalyticsRequestBuilder( const val SCREEN_NAME_UPLOAD_CAPTURE = "upload_capture" const val SCREEN_NAME_SUPPORT = "support" const val SCREEN_NAME_SELFIE = "selfie" + const val SCREEN_NAME_DOCUMENT_VERIFICATION = "document_verification" + const val SCREEN_NAME_TAX_PIN_VERIFICATION = "tax_pin_verification" + const val SCREEN_NAME_CONFIRMATION = "confirmation" } } \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/analytics/ModelPerformanceMonitor.kt b/identity/src/main/java/io/falu/identity/analytics/ModelPerformanceMonitor.kt index 29dbbf46..0a2757c9 100644 --- a/identity/src/main/java/io/falu/identity/analytics/ModelPerformanceMonitor.kt +++ b/identity/src/main/java/io/falu/identity/analytics/ModelPerformanceMonitor.kt @@ -45,7 +45,8 @@ internal class ModelPerformanceMonitor( preprocessing = preprocessingStats.duration(), imageInfo = preprocessingStats.lastOrNull()?.result, frames = preprocessingStats.size - ), IdentityAnalyticsRequestBuilder.ORIGIN + ), + IdentityAnalyticsRequestBuilder.ORIGIN ) preprocessingStats.clear() diff --git a/identity/src/main/java/io/falu/identity/api/DocumentUploadDisposition.kt b/identity/src/main/java/io/falu/identity/api/DocumentUploadDisposition.kt index c361e57b..5b61f53b 100644 --- a/identity/src/main/java/io/falu/identity/api/DocumentUploadDisposition.kt +++ b/identity/src/main/java/io/falu/identity/api/DocumentUploadDisposition.kt @@ -21,12 +21,11 @@ internal data class DocumentUploadDisposition( fun modify( documentSide: DocumentSide, result: VerificationUploadResult - ) = - if (documentSide == DocumentSide.FRONT) { - this.copy(front = result) - } else { - this.copy(back = result) - } + ) = if (documentSide == DocumentSide.FRONT) { + this.copy(front = result) + } else { + this.copy(back = result) + } val isFrontUpload: Boolean get() = front != null @@ -38,7 +37,7 @@ internal data class DocumentUploadDisposition( get() = front != null && back != null fun generateVerificationUploadRequest(identityDocumentType: IdentityDocumentType): - VerificationUploadRequest { + VerificationUploadRequest { val front = VerificationDocumentSide( method = front!!.method!!, file = front!!.file.id, diff --git a/identity/src/main/java/io/falu/identity/api/FilesApiClient.kt b/identity/src/main/java/io/falu/identity/api/FilesApiClient.kt index f2eb6e38..41b987a9 100644 --- a/identity/src/main/java/io/falu/identity/api/FilesApiClient.kt +++ b/identity/src/main/java/io/falu/identity/api/FilesApiClient.kt @@ -88,8 +88,9 @@ internal class FilesApiClient : AbstractHttpApiClient(EmptyAuthenticationProvide } } - 400 -> errorModel = - gson.fromJson(body.charStream(), HttpApiResponseProblem::class.java) + 400 -> + errorModel = + gson.fromJson(body.charStream(), HttpApiResponseProblem::class.java) } body.close() } diff --git a/identity/src/main/java/io/falu/identity/api/models/IdentityDocumentType.kt b/identity/src/main/java/io/falu/identity/api/models/IdentityDocumentType.kt index fbb2aebb..320a7e3e 100644 --- a/identity/src/main/java/io/falu/identity/api/models/IdentityDocumentType.kt +++ b/identity/src/main/java/io/falu/identity/api/models/IdentityDocumentType.kt @@ -1,8 +1,10 @@ package io.falu.identity.api.models +import android.content.Context import androidx.annotation.StringRes import com.google.gson.annotations.SerializedName import io.falu.identity.R +import io.falu.identity.scan.ScanDisposition internal enum class IdentityDocumentType { @SerializedName("id_card") @@ -23,4 +25,33 @@ internal enum class IdentityDocumentType { DRIVING_LICENSE -> R.string.document_selection_document_driver_license } } +} + +internal fun IdentityDocumentType.getIdentityDocumentName(context: Context) = + context.getString(this.titleRes) + +internal fun IdentityDocumentType.getScanType(): + Pair { + return when (this) { + IdentityDocumentType.IDENTITY_CARD -> { + Pair( + ScanDisposition.DocumentScanType.ID_FRONT, + ScanDisposition.DocumentScanType.ID_BACK + ) + } + + IdentityDocumentType.PASSPORT -> { + Pair( + ScanDisposition.DocumentScanType.PASSPORT, + null + ) + } + + IdentityDocumentType.DRIVING_LICENSE -> { + Pair( + ScanDisposition.DocumentScanType.DL_FRONT, + ScanDisposition.DocumentScanType.DL_BACK + ) + } + } } \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/api/models/requirements/RequirementError.kt b/identity/src/main/java/io/falu/identity/api/models/requirements/RequirementError.kt index 5315b8d8..9aff918a 100644 --- a/identity/src/main/java/io/falu/identity/api/models/requirements/RequirementError.kt +++ b/identity/src/main/java/io/falu/identity/api/models/requirements/RequirementError.kt @@ -1,38 +1,7 @@ package io.falu.identity.api.models.requirements -import androidx.annotation.IdRes -import io.falu.identity.R - internal data class RequirementError( var requirement: RequirementType?, var code: String, var description: String -) { - internal companion object { - private val document_upload_ids = arrayOf( - R.id.fragment_manual_capture, - R.id.fragment_upload_capture - ) - - /** - * Navigate to the fragment where the error was experienced. - */ - fun RequirementError.canNavigateBackTo(@IdRes source: Int) = - when (requirement) { - RequirementType.CONSENT -> { - source == R.id.fragment_welcome - } - RequirementType.COUNTRY, - RequirementType.DOCUMENT_TYPE -> { - source == R.id.fragment_document_selection - } - RequirementType.DOCUMENT_FRONT, - RequirementType.DOCUMENT_BACK -> { - document_upload_ids.contains(source) - } - RequirementType.SELFIE, - RequirementType.VIDEO -> true - else -> false - } - } -} \ No newline at end of file +) \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/api/models/requirements/RequirementType.kt b/identity/src/main/java/io/falu/identity/api/models/requirements/RequirementType.kt index 013388cb..0df2fa1c 100644 --- a/identity/src/main/java/io/falu/identity/api/models/requirements/RequirementType.kt +++ b/identity/src/main/java/io/falu/identity/api/models/requirements/RequirementType.kt @@ -1,6 +1,13 @@ package io.falu.identity.api.models.requirements import com.google.gson.annotations.SerializedName +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.navigation.DocumentCaptureDestination +import io.falu.identity.navigation.DocumentSelectionDestination +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.navigation.SelfieDestination +import io.falu.identity.navigation.TaxPinDestination +import io.falu.identity.navigation.WelcomeDestination internal enum class RequirementType { @SerializedName("consent") @@ -21,6 +28,70 @@ internal enum class RequirementType { @SerializedName("selfie") SELFIE, + @SerializedName("tax_id") + TAX, + @SerializedName("video") - VIDEO + VIDEO; + + internal companion object { + fun MutableList?.nextDestination( + navActions: IdentityVerificationNavActions, + verification: Verification + ) { + when { + isNullOrEmpty() -> { + navActions.navigateToWelcome() + } + + contains(CONSENT) -> { + navActions.navigateToWelcome() + } + + intersect(listOf(COUNTRY, DOCUMENT_TYPE)).isNotEmpty() -> { + navActions.navigateToDocumentSelection() + } + + intersect(listOf(DOCUMENT_FRONT, DOCUMENT_BACK)).isNotEmpty() -> { + navActions.navigateToDocumentCaptureMethods(verification.options.document.allowed.first()) + } + + contains(SELFIE) -> { + navActions.navigateToSelfie() + } + + contains(TAX) -> { + navActions.navigateToTaxPin() + } + } + } + + fun RequirementType.matchesFromRoute(fromRoute: String) = + when (this) { + CONSENT -> { + fromRoute == WelcomeDestination.ROUTE.route + } + + COUNTRY, DOCUMENT_TYPE -> { + fromRoute == DocumentSelectionDestination.ROUTE.route + } + + DOCUMENT_FRONT, + DOCUMENT_BACK -> { + fromRoute == DocumentCaptureDestination.ROUTE.route + } + + SELFIE -> { + fromRoute == SelfieDestination.ROUTE.route + } + + TAX -> { + fromRoute == TaxPinDestination.ROUTE.route + } + + VIDEO -> { + false + } + } + } } \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/api/models/verification/Verification.kt b/identity/src/main/java/io/falu/identity/api/models/verification/Verification.kt index 019db2cf..1a94504b 100644 --- a/identity/src/main/java/io/falu/identity/api/models/verification/Verification.kt +++ b/identity/src/main/java/io/falu/identity/api/models/verification/Verification.kt @@ -33,6 +33,9 @@ internal data class Verification( var capture: VerificationCapture, var supported: Boolean = true ) { + val idNumberVerification: Boolean + get() = type == VerificationType.IDENTITY_NUMBER + val selfieRequired: Boolean get() = options.selfie != null diff --git a/identity/src/main/java/io/falu/identity/api/models/verification/VerificationUploadRequest.kt b/identity/src/main/java/io/falu/identity/api/models/verification/VerificationUploadRequest.kt index 8ba23277..984b9699 100644 --- a/identity/src/main/java/io/falu/identity/api/models/verification/VerificationUploadRequest.kt +++ b/identity/src/main/java/io/falu/identity/api/models/verification/VerificationUploadRequest.kt @@ -1,15 +1,7 @@ package io.falu.identity.api.models.verification -import android.content.Intent -import android.os.Bundle -import android.os.Parcelable -import androidx.core.os.bundleOf import com.google.gson.annotations.SerializedName -import io.falu.identity.IdentityVerificationResult -import io.falu.identity.utils.parcelable -import kotlinx.parcelize.Parcelize -@Parcelize internal data class VerificationUploadRequest( var consent: Boolean = true, var country: String? = null, @@ -19,20 +11,4 @@ internal data class VerificationUploadRequest( var idNumber: VerificationIdNumberUpload? = null, @SerializedName("tax_id") var taxPin: VerificationTaxPinUpload? = null -) : Parcelable { - - @JvmSynthetic - fun addToBundle() = bundleOf(KEY_VERIFICATION_UPLOAD_REQUEST to this) - - internal companion object { - private const val KEY_VERIFICATION_UPLOAD_REQUEST = ":verification" - - fun getFromBundle(bundle: Bundle?): VerificationUploadRequest? { - return bundle?.parcelable(KEY_VERIFICATION_UPLOAD_REQUEST) - } - - fun getFromIntent(intent: Intent?): IdentityVerificationResult? { - return intent?.parcelable(KEY_VERIFICATION_UPLOAD_REQUEST) - } - } -} \ No newline at end of file +) \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/camera/CameraView.kt b/identity/src/main/java/io/falu/identity/camera/CameraView.kt index 3642b4e5..7897a5e6 100644 --- a/identity/src/main/java/io/falu/identity/camera/CameraView.kt +++ b/identity/src/main/java/io/falu/identity/camera/CameraView.kt @@ -1,9 +1,6 @@ package io.falu.identity.camera -import android.app.Activity import android.content.Context -import android.hardware.camera2.CameraCharacteristics -import android.hardware.camera2.CameraManager import android.os.Build import android.util.AttributeSet import android.util.DisplayMetrics @@ -13,9 +10,7 @@ import android.widget.FrameLayout import android.widget.ImageView import androidx.annotation.DrawableRes import androidx.camera.core.Camera -import androidx.camera.core.CameraInfo import androidx.camera.core.CameraSelector -import androidx.camera.core.CameraState import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider @@ -23,12 +18,15 @@ import androidx.camera.view.PreviewView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import io.falu.identity.R -import io.falu.identity.api.models.CameraLens -import io.falu.identity.api.models.CameraSettings -import io.falu.identity.api.models.Exposure +import io.falu.identity.utils.getActivity import io.falu.identity.utils.size +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors import kotlin.math.max import kotlin.math.min @@ -36,12 +34,7 @@ internal class CameraView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr) { - - /** - * - */ - private lateinit var onCameraInfoAvailable: (CameraInfo) -> Unit +) : ConstraintLayout(context, attrs, defStyleAttr), LifecycleEventObserver { /** * @@ -56,9 +49,7 @@ internal class CameraView @JvmOverloads constructor( /** * */ - private val ivCameraBorder: ImageView - - private lateinit var _lifecycleOwner: LifecycleOwner + internal val ivCameraBorder: ImageView private var _cameraViewType: CameraViewType = CameraViewType.DEFAULT private var _lensFacing: Int = CameraSelector.LENS_FACING_BACK @@ -70,14 +61,9 @@ internal class CameraView @JvmOverloads constructor( private var preview: Preview? = null private var imageAnalysis: ImageAnalysis? = null private var camera: Camera? = null - private var cameraProvider: ProcessCameraProvider? = null - /** - * Detects, characterizes, and connects to a CameraDevice (used for all camera operations) - */ - private val cameraManager: CameraManager by lazy { - context.getSystemService(Context.CAMERA_SERVICE) as CameraManager - } + private val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + private var cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() /** * The direction of the camera, front or back @@ -91,11 +77,7 @@ internal class CameraView @JvmOverloads constructor( /** * The lifecycle owner */ - var lifecycleOwner: LifecycleOwner - get() = _lifecycleOwner - set(value) { - _lifecycleOwner = value - } + private lateinit var lifecycleOwner: LifecycleOwner /** * @@ -111,22 +93,18 @@ internal class CameraView @JvmOverloads constructor( */ val analyzers: MutableList = mutableListOf() - /** - * - */ private val displayInfo by lazy { - val activity = context as Activity - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - activity.display + requireNotNull(context.getActivity()).display } else { null - } ?: @Suppress("Deprecation") - activity.windowManager.defaultDisplay + } + ?: @Suppress("Deprecation") + requireNotNull(context.getActivity()).windowManager.defaultDisplay } private val displayRotation by lazy { displayInfo.rotation } - private val displayMetrics by lazy { DisplayMetrics().also { display.getRealMetrics(it) } } + private val displayMetrics by lazy { DisplayMetrics().also { displayInfo.getRealMetrics(it) } } private val displaySize by lazy { Size( displayMetrics.widthPixels, @@ -156,11 +134,16 @@ internal class CameraView @JvmOverloads constructor( } } - /** - * - */ - fun setCameraInfoListener(onCameraInfoAvailable: (CameraInfo) -> Unit) { - this.onCameraInfoAvailable = onCameraInfoAvailable + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_DESTROY -> onDestroyed() + Lifecycle.Event.ON_PAUSE -> onPause() + Lifecycle.Event.ON_CREATE -> onCreate() + Lifecycle.Event.ON_START -> onStart() + Lifecycle.Event.ON_RESUME -> onResume() + Lifecycle.Event.ON_STOP -> onStop() + Lifecycle.Event.ON_ANY -> onAny() + } } /** @@ -186,31 +169,24 @@ internal class CameraView @JvmOverloads constructor( * */ private fun configureCamera() { - val cameraProviderFuture = ProcessCameraProvider.getInstance(context) - cameraProviderFuture.addListener({ - cameraProvider = cameraProviderFuture.get() - - bindCameraUseCases() - }, ContextCompat.getMainExecutor(context)) + withCameraProvider { + bindCameraUseCases(it) + } } - /** - * Declare and bind preview, capture and analysis use cases - */ - private fun bindCameraUseCases() { - val cameraProvider = cameraProvider - ?: throw IllegalStateException("Camera initialization failed.") - + @Synchronized + private fun bindCameraUseCases(cameraProvider: ProcessCameraProvider) { val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build() - preview = Preview.Builder() - .setTargetRotation(displayRotation) - .setTargetResolution(viewCameraPreview.size()) - .build() - .also { - it.setSurfaceProvider(viewCameraPreview.surfaceProvider) - } - + if (preview == null) { + preview = Preview.Builder() + .setTargetRotation(displayRotation) + .setTargetResolution(viewCameraPreview.size()) + .build() + .also { + it.setSurfaceProvider(viewCameraPreview.surfaceProvider) + } + } imageAnalysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setTargetRotation(displayRotation) @@ -218,12 +194,15 @@ internal class CameraView @JvmOverloads constructor( .setImageQueueDepth(1) .build() .also { - it.setAnalyzer(ContextCompat.getMainExecutor(context), LumaAnalyzer { luma -> - brightness = luma - }) + it.setAnalyzer( + cameraExecutor, + LumaAnalyzer { luma -> + brightness = luma + } + ) analyzers.forEach { analyzer -> - it.setAnalyzer(ContextCompat.getMainExecutor(context), analyzer) + it.setAnalyzer(cameraExecutor, analyzer) } } @@ -240,57 +219,75 @@ internal class CameraView @JvmOverloads constructor( val surfaceProvider = viewCameraPreview.surfaceProvider preview?.setSurfaceProvider(surfaceProvider) - } catch (e: Exception) { + } catch (e: Throwable) { Log.e(TAG, "Use case binding failed", e) } } + internal fun startAnalyzer() { + cameraExecutor = Executors.newSingleThreadExecutor() + withCameraProvider { + configureCamera() + } + } + internal fun stopAnalyzer() { imageAnalysis?.clearAnalyzer() } - internal fun startAnalyzer() { - imageAnalysis?.also { - analyzers.forEach { analyzer -> - it.setAnalyzer(ContextCompat.getMainExecutor(context), analyzer) - } + private fun onDestroyed() { + withCameraProvider { + it.unbindAll() + cameraExecutor.shutdown() } } - /** - * */ - private fun observeCameraState(cameraInfo: CameraInfo?) { - cameraInfo?.cameraState?.observe(lifecycleOwner) { - when (it.type) { - CameraState.Type.OPEN -> { - onCameraInfoAvailable(cameraInfo) - } + private fun onPause() { + } + + private fun onCreate() { + cameraExecutor = Executors.newSingleThreadExecutor() + viewCameraPreview.post { + configureCamera() + } + } - else -> {} + private fun onStart() { + } + + private fun onResume() { + } + + private fun onStop() { + } + + private fun onAny() { + } + + internal fun unbindFromLifecycle(lifecycleOwner: LifecycleOwner) { + lifecycleOwner.lifecycle.removeObserver(this) + withCameraProvider { cameraProvider -> + preview?.let { preview -> + cameraProvider.unbind(preview) } } + onPause() + } + + internal fun bindLifecycle(lifecycleOwner: LifecycleOwner) { + lifecycleOwner.lifecycle.addObserver(this) + this.lifecycleOwner = lifecycleOwner } /** - * + * Run a task with the camera provider. */ - internal val cameraSettings: CameraSettings - get() { - val extensions = cameraManager.getCameraCharacteristics(cameraId) - val focalLength = extensions.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.first() - val duration = - extensions.get(CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE)?.lower ?: Float.MIN_VALUE - val iso = extensions.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE)?.upper ?: Float.MIN_VALUE - - return CameraSettings( - lens = CameraLens(model = "Camera-X", focalLength = focalLength!!), - brightness = brightness.toFloat(), - exposure = Exposure(iso = iso.toFloat(), duration = duration.toFloat()) - ) - } - - private val cameraId: String - get() = cameraManager.cameraIdList.first() + private fun withCameraProvider( + executor: Executor = ContextCompat.getMainExecutor(context), + task: (ProcessCameraProvider) -> Unit + ) { + cameraProviderFuture.addListener({ task(cameraProviderFuture.get()) }, executor) + } private fun Size.toResolution(display: Size) = when { display.width >= display.height -> Size( diff --git a/identity/src/main/java/io/falu/identity/camera/LumaAnalyzer.kt b/identity/src/main/java/io/falu/identity/camera/LumaAnalyzer.kt index 2d3119b0..8108bbef 100644 --- a/identity/src/main/java/io/falu/identity/camera/LumaAnalyzer.kt +++ b/identity/src/main/java/io/falu/identity/camera/LumaAnalyzer.kt @@ -28,8 +28,9 @@ internal class LumaAnalyzer(val listener: LumaListener) : ImageAnalysis.Analyzer // Compute the FPS using a moving average while (frameTimestamps.size >= frameRateWindow) frameTimestamps.removeLast() - framesPerSecond = 1.0 / ((frameTimestamps.peekFirst() - - frameTimestamps.peekLast()) / frameTimestamps.size.toDouble()) * 1000.0 + framesPerSecond = 1.0 / ( + (frameTimestamps.peekFirst() - frameTimestamps.peekLast()) / frameTimestamps.size.toDouble() + ) * 1000.0 // Calculate the average luma no more often than every second if (frameTimestamps.first - lastAnalyzedTimestamp >= FRAME_THRESHOLD) { diff --git a/identity/src/main/java/io/falu/identity/capture/AbstractCaptureFragment.kt b/identity/src/main/java/io/falu/identity/capture/AbstractCaptureFragment.kt deleted file mode 100644 index 19379741..00000000 --- a/identity/src/main/java/io/falu/identity/capture/AbstractCaptureFragment.kt +++ /dev/null @@ -1,238 +0,0 @@ -package io.falu.identity.capture - -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.annotation.IdRes -import androidx.annotation.VisibleForTesting -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.core.exceptions.ApiException -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.ai.DocumentDetectionOutput -import io.falu.identity.ai.DocumentEngine -import io.falu.identity.analytics.AnalyticsDisposition -import io.falu.identity.api.DocumentUploadDisposition -import io.falu.identity.api.models.DocumentSide -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.UploadMethod -import io.falu.identity.api.models.verification.VerificationUpdateOptions -import io.falu.identity.api.models.verification.VerificationUploadRequest -import io.falu.identity.camera.CameraPermissionsFragment -import io.falu.identity.capture.scan.DocumentScanViewModel -import io.falu.identity.documents.DocumentSelectionFragment -import io.falu.identity.scan.ScanDisposition -import io.falu.identity.utils.loadDocumentDetectionModel -import io.falu.identity.utils.matches -import io.falu.identity.utils.navigateToApiResponseProblemFragment -import io.falu.identity.utils.navigateToErrorFragment -import io.falu.identity.utils.rotate -import io.falu.identity.utils.serializable -import io.falu.identity.utils.submitVerificationData -import io.falu.identity.utils.toBitmap -import io.falu.identity.utils.updateVerification - -internal abstract class AbstractCaptureFragment( - identityViewModelFactory: ViewModelProvider.Factory -) : CameraPermissionsFragment() { - - protected val identityViewModel: IdentityVerificationViewModel by activityViewModels { identityViewModelFactory } - - private val documentScanViewModel: DocumentScanViewModel by activityViewModels { documentScanViewModelFactory } - - @VisibleForTesting - internal var documentScanViewModelFactory: ViewModelProvider.Factory = - DocumentScanViewModel.factoryProvider(this) { identityViewModel.modelPerformanceMonitor } - - @VisibleForTesting - internal var captureDocumentViewModelFactory: ViewModelProvider.Factory = - CaptureDocumentViewModel.CaptureDocumentViewModelFactory { this } - - protected val captureDocumentViewModel: CaptureDocumentViewModel by viewModels { - captureDocumentViewModelFactory - } - - protected lateinit var identityDocumentType: IdentityDocumentType - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - identityDocumentType = requireArguments().serializable(DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE)!! - - uploadStateObservations() - } - - /** - * Determine if the type of document provided manually or uploaded is valid - * - * @param uri - */ - protected fun analyze( - uri: Uri, - scanType: ScanDisposition.DocumentScanType, - documentSide: DocumentSide, - type: UploadMethod = UploadMethod.MANUAL - ) { - val bitmap = uri.toBitmap(requireContext().contentResolver).rotate(90) - - loadDocumentDetectionModel(identityViewModel, documentScanViewModel, threshold) { - val engine = DocumentEngine(it, threshold, identityViewModel.modelPerformanceMonitor) - val output = engine.analyze(bitmap) as DocumentDetectionOutput - - if (output.score >= threshold && output.option.matches(scanType)) { - reportSuccessfulAnalysisTelemetry(documentSide, output) - uploadDocument(output.bitmap, documentSide, type) - } else { - findNavController().navigate(R.id.action_global_fragment_scan_capture_error) - } - } - } - - private fun reportSuccessfulAnalysisTelemetry(documentSide: DocumentSide, output: DocumentDetectionOutput) { - val telemetryDisposition = if (documentSide == DocumentSide.FRONT) { - AnalyticsDisposition(frontModelScore = output.score) - } else { - AnalyticsDisposition(backModelScore = output.score) - } - - identityViewModel.modifyAnalyticsDisposition(disposition = telemetryDisposition) - documentScanViewModel.reportModelPerformance() - } - - private fun uploadDocument( - bitmap: Bitmap, - documentSide: DocumentSide, - type: UploadMethod = UploadMethod.MANUAL - ) { - if (documentSide == DocumentSide.FRONT) { - showDocumentFrontUploading() - } else { - showDocumentBackUploading() - } - - identityViewModel.uploadVerificationDocument( - bitmap, - documentSide, - type = type, - onError = { - resetViews(documentSide) - navigateToApiResponseProblemFragment((it as ApiException).problem) - }, - onFailure = { - resetViews(documentSide) - navigateToErrorFragment(it) - }) - } - - protected fun uploadScannedDocument( - output: DocumentDetectionOutput, - documentSide: DocumentSide - ) { - if (documentSide == DocumentSide.FRONT) { - showDocumentFrontUploading() - } else { - showDocumentBackUploading() - } - - identityViewModel.uploadScannedDocument( - output.bitmap, - documentSide, - output.score, - onError = { - resetViews(documentSide) - navigateToApiResponseProblemFragment((it as ApiException).problem) - }, - onFailure = { - resetViews(documentSide) - navigateToErrorFragment(it) - }) - } - - protected abstract fun showDocumentFrontUploading() - - protected abstract fun showDocumentBackUploading() - - protected abstract fun showDocumentFrontDoneUploading(disposition: DocumentUploadDisposition) - - protected abstract fun showDocumentBackDoneUploading() - - protected abstract fun showBothSidesUploaded(disposition: DocumentUploadDisposition) - - protected abstract fun resetViews(documentSide: DocumentSide) - - protected val isPassport: Boolean - get() = identityDocumentType == IdentityDocumentType.PASSPORT - - private fun uploadStateObservations() { - identityViewModel.documentUploadDisposition.observe(viewLifecycleOwner) { - if (it.isFrontUpload) { - showDocumentFrontDoneUploading(it) - } - - if (it.isBackUploaded) { - showDocumentBackDoneUploading() - } - - if (identityDocumentType != IdentityDocumentType.PASSPORT) { - if (it.isBothUploadLoad) { - showBothSidesUploaded(it) - } - } - } - } - - protected fun updateVerificationAndAttemptDocumentSubmission( - @IdRes source: Int, - verificationRequest: VerificationUploadRequest - ) { - val patchRequest = VerificationUpdateOptions(document = verificationRequest.document) - - updateVerification(identityViewModel, patchRequest, source, onSuccess = { - attemptDocumentSubmission(source, verificationRequest) - }) - } - - private fun attemptDocumentSubmission( - @IdRes source: Int, - verificationRequest: VerificationUploadRequest - ) { - identityViewModel.observeForVerificationResults( - viewLifecycleOwner, - onSuccess = { verification -> - when { - verification.selfieRequired -> { - findNavController().navigate( - R.id.action_global_fragment_selfie, - verificationRequest.addToBundle() - ) - } - - verification.taxPinRequired -> { - findNavController().navigate( - R.id.action_global_fragment_tax_pin_verification, - verificationRequest.addToBundle() - ) - } - - else -> { - submitVerificationData(identityViewModel, source, verificationRequest) - } - } - }, - onError = { - navigateToApiResponseProblemFragment((it as ApiException).problem) - } - ) - } - - internal companion object { - fun IdentityDocumentType.getIdentityDocumentName(context: Context) = - context.getString(this.titleRes) - - private const val threshold = 0.75f - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/CaptureDocumentViewModel.kt b/identity/src/main/java/io/falu/identity/capture/CaptureDocumentViewModel.kt deleted file mode 100644 index 6f4aa45b..00000000 --- a/identity/src/main/java/io/falu/identity/capture/CaptureDocumentViewModel.kt +++ /dev/null @@ -1,95 +0,0 @@ -package io.falu.identity.capture - -import android.content.Context -import android.net.Uri -import androidx.activity.result.ActivityResultCaller -import androidx.fragment.app.Fragment -import androidx.lifecycle.AbstractSavedStateViewModelFactory -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.savedstate.SavedStateRegistryOwner -import io.falu.identity.utils.FileUtils - -internal class CaptureDocumentViewModel( - private val stateHandle: SavedStateHandle -) : ViewModel() { - - private lateinit var imageCaptureFront: ImageCapture - private lateinit var imageCaptureBack: ImageCapture - private lateinit var frontImagePicker: ImagePicker - private lateinit var backImagePicker: ImagePicker - - /** - * - */ - fun captureDocumentImages( - caller: ActivityResultCaller, - utils: FileUtils, - onFrontImageCaptured: (Uri) -> Unit, - onBackImageCaptured: (Uri) -> Unit - ) { - imageCaptureFront = - ImageCapture(caller, utils, stateHandle, KEY_FRONT_IMAGE_URI, onFrontImageCaptured) - imageCaptureBack = - ImageCapture(caller, utils, stateHandle, KEY_BACK_IMAGE_URI, onBackImageCaptured) - } - - /** - * - */ - fun pickDocumentImages( - fragment: Fragment, - onFrontImagePicked: (Uri) -> Unit, - onBackImagePicked: (Uri) -> Unit - ) { - frontImagePicker = ImagePicker(fragment, onFrontImagePicked) - backImagePicker = ImagePicker(fragment, onBackImagePicked) - } - - /** - * - */ - fun captureImageFront(context: Context) { - imageCaptureFront.captureImage(context) - } - - /** - * - */ - fun captureImageBack(context: Context) { - imageCaptureBack.captureImage(context) - } - - /** - * Pick an image for front. - */ - fun pickImageFront() { - frontImagePicker.pickImage() - } - - /** - * Pick an image for back. - */ - fun pickImageBack() { - backImagePicker.pickImage() - } - - internal class CaptureDocumentViewModelFactory( - ownerProvider: () -> SavedStateRegistryOwner - ) : AbstractSavedStateViewModelFactory(ownerProvider(), null) { - - @Suppress("UNCHECKED_CAST") - override fun create( - key: String, - modelClass: Class, - handle: SavedStateHandle - ): T { - return CaptureDocumentViewModel(handle) as T - } - } - - internal companion object { - const val KEY_FRONT_IMAGE_URI = ":front" - const val KEY_BACK_IMAGE_URI = ":back" - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/manual/ManualCaptureFragment.kt b/identity/src/main/java/io/falu/identity/capture/manual/ManualCaptureFragment.kt deleted file mode 100644 index f3e9c5ef..00000000 --- a/identity/src/main/java/io/falu/identity/capture/manual/ManualCaptureFragment.kt +++ /dev/null @@ -1,148 +0,0 @@ -package io.falu.identity.capture.manual - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.ViewModelProvider -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_MANUAL_CAPTURE -import io.falu.identity.api.DocumentUploadDisposition -import io.falu.identity.api.models.DocumentSide -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.UploadMethod -import io.falu.identity.capture.AbstractCaptureFragment -import io.falu.identity.capture.scan.ScanCaptureFragment.Companion.getScanType -import io.falu.identity.databinding.FragmentManualCaptureBinding -import io.falu.identity.utils.FileUtils - -internal class ManualCaptureFragment(identityViewModelFactory: ViewModelProvider.Factory) : - AbstractCaptureFragment(identityViewModelFactory) { - - private var _binding: FragmentManualCaptureBinding? = null - private val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val fileUtils = FileUtils(requireContext()) - - captureDocumentViewModel.captureDocumentImages( - this, - fileUtils, - onFrontImageCaptured = { - analyze(uri = it, identityDocumentType.getScanType().first, DocumentSide.FRONT, UploadMethod.MANUAL) - }, - onBackImageCaptured = { - identityDocumentType.getScanType().second?.let { scanType -> - analyze( - uri = it, - scanType, - DocumentSide.BACK, - UploadMethod.MANUAL - ) - } - } - ) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentManualCaptureBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - identityViewModel.reportTelemetry( - identityViewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_MANUAL_CAPTURE) - ) - - binding.cardDocumentBack.visibility = - if (isPassport) View.GONE else View.VISIBLE - binding.buttonContinue.isEnabled = false - - binding.tvUploadTitle.text = - getString( - R.string.upload_document_capture_title, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - binding.tvCardFront.text = - getString( - R.string.upload_document_capture_document_font, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - binding.tvCardBack.text = - getString( - R.string.upload_document_capture_document_back, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - - binding.buttonSelectFront.setOnClickListener { - captureDocumentViewModel.captureImageFront(requireContext()) - } - - binding.buttonSelectBack.setOnClickListener { - captureDocumentViewModel.captureImageBack(requireContext()) - } - - binding.buttonContinue.text = getString(R.string.button_continue) - binding.buttonContinue.setOnClickListener { - binding.buttonContinue.showProgress() - val disposition = binding.buttonContinue.tag as DocumentUploadDisposition - - updateVerificationAndAttemptDocumentSubmission( - source = R.id.action_fragment_document_capture_methods_to_fragment_manual_capture, - disposition.generateVerificationUploadRequest(identityDocumentType) - ) - } - } - - override fun showDocumentFrontUploading() { - binding.buttonSelectFront.visibility = View.GONE - binding.progressSelectFront.visibility = View.VISIBLE - binding.ivFrontUploaded.visibility = View.GONE - } - - override fun showDocumentBackUploading() { - binding.buttonSelectBack.visibility = View.GONE - binding.progressSelectBack.visibility = View.VISIBLE - binding.ivBackUploaded.visibility = View.GONE - } - - override fun showDocumentFrontDoneUploading(disposition: DocumentUploadDisposition) { - binding.buttonSelectFront.visibility = View.GONE - binding.progressSelectFront.visibility = View.GONE - binding.ivFrontUploaded.visibility = View.VISIBLE - - if (identityDocumentType == IdentityDocumentType.PASSPORT) { - binding.buttonContinue.isEnabled = true - binding.buttonContinue.tag = disposition - } - } - - override fun showDocumentBackDoneUploading() { - binding.buttonSelectBack.visibility = View.GONE - binding.progressSelectBack.visibility = View.GONE - binding.ivBackUploaded.visibility = View.VISIBLE - } - - override fun resetViews(documentSide: DocumentSide) { - if (documentSide == DocumentSide.FRONT) { - binding.buttonSelectFront.visibility = View.VISIBLE - binding.progressSelectFront.visibility = View.GONE - binding.ivFrontUploaded.visibility = View.GONE - } else { - binding.buttonSelectBack.visibility = View.VISIBLE - binding.progressSelectBack.visibility = View.GONE - binding.ivBackUploaded.visibility = View.GONE - } - } - - override fun showBothSidesUploaded(disposition: DocumentUploadDisposition) { - binding.buttonContinue.isEnabled = true - binding.buttonContinue.tag = disposition - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/scan/DocumentScanner.kt b/identity/src/main/java/io/falu/identity/capture/scan/DocumentScanner.kt deleted file mode 100644 index f46b8edb..00000000 --- a/identity/src/main/java/io/falu/identity/capture/scan/DocumentScanner.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.falu.identity.capture.scan - -import android.renderscript.RenderScript -import io.falu.identity.ai.DocumentDetectionAnalyzer -import io.falu.identity.analytics.ModelPerformanceMonitor -import io.falu.identity.api.models.verification.VerificationCapture -import io.falu.identity.camera.CameraView -import io.falu.identity.scan.AbstractScanner -import io.falu.identity.scan.IdentityResult -import io.falu.identity.scan.ProvisionalResult -import io.falu.identity.scan.ScanDisposition -import io.falu.identity.scan.ScanResultCallback -import io.falu.identity.utils.toFraction -import org.joda.time.DateTime -import java.io.File - -internal class DocumentScanner( - private val model: File, - private val threshold: Float, - private val performanceMonitor: ModelPerformanceMonitor, - callbacks: ScanResultCallback -) : AbstractScanner(callbacks) { - - override fun scan( - view: CameraView, - scanType: ScanDisposition.DocumentScanType, - capture: VerificationCapture, - renderScript: RenderScript - ) { - val machine = DocumentDispositionMachine( - timeout = DateTime.now().plusMillis(capture.timeout), - iou = capture.blur?.iou?.toFraction() ?: 0.95f, - requiredTime = capture.blur?.duration?.div(1000) ?: 5 - ) - - disposition = ScanDisposition.Start(scanType, machine) - - view.analyzers.add( - DocumentDetectionAnalyzer - .Builder(model = model, threshold, renderScript, performanceMonitor) - .instance { onResult(it) } - ) - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/scan/ScanCaptureFragment.kt b/identity/src/main/java/io/falu/identity/capture/scan/ScanCaptureFragment.kt deleted file mode 100644 index 2ea34b15..00000000 --- a/identity/src/main/java/io/falu/identity/capture/scan/ScanCaptureFragment.kt +++ /dev/null @@ -1,279 +0,0 @@ -package io.falu.identity.capture.scan - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.camera.core.CameraSelector -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.ai.DocumentDetectionOutput -import io.falu.identity.analytics.AnalyticsDisposition -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_AUTO_CAPTURE -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.api.models.verification.VerificationCapture -import io.falu.identity.camera.CameraView -import io.falu.identity.capture.AbstractCaptureFragment.Companion.getIdentityDocumentName -import io.falu.identity.databinding.FragmentScanCaptureBinding -import io.falu.identity.documents.DocumentSelectionFragment -import io.falu.identity.scan.ScanDisposition -import io.falu.identity.scan.ScanResult -import io.falu.identity.utils.FileUtils -import io.falu.identity.utils.getRenderScript -import io.falu.identity.utils.serializable - -internal class ScanCaptureFragment(identityViewModelFactory: ViewModelProvider.Factory) : Fragment() { - - private val identityViewModel: IdentityVerificationViewModel by activityViewModels { identityViewModelFactory } - private val documentScanViewModel: DocumentScanViewModel by activityViewModels { documentScanViewModelFactory } - private val documentScanViewModelFactory = - DocumentScanViewModel.factoryProvider(this) { identityViewModel.modelPerformanceMonitor } - - private var _binding: FragmentScanCaptureBinding? = null - private val binding get() = _binding!! - - private var scanType: ScanDisposition.DocumentScanType? = null - private lateinit var identityDocumentType: IdentityDocumentType - - private lateinit var fileUtils: FileUtils - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentScanCaptureBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - identityViewModel.reportTelemetry( - identityViewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_AUTO_CAPTURE) - ) - - identityDocumentType = - requireArguments().serializable(DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE)!! - - scanType = - requireArguments().serializable(KEY_DOCUMENT_SCAN_TYPE) as? ScanDisposition.DocumentScanType - - fileUtils = FileUtils(requireContext()) - - documentScanViewModel.resetScanDispositions() - - resetUI() - - binding.viewCamera.lifecycleOwner = viewLifecycleOwner - binding.viewCamera.lensFacing = CameraSelector.LENS_FACING_BACK - - binding.viewCamera.cameraViewType = - if (identityDocumentType != IdentityDocumentType.PASSPORT) - CameraView.CameraViewType.ID - else - CameraView.CameraViewType.PASSPORT - - identityViewModel.observeForVerificationResults( - viewLifecycleOwner, - onSuccess = { onVerificationPage() }, - onError = {} - ) - - documentScanViewModel.documentScanDisposition.observe(viewLifecycleOwner) { - updateUI(it) - } - - binding.buttonContinue.setOnClickListener { - val result = binding.buttonContinue.tag as ScanResult - when { - scanType!!.isFront -> { - setFragmentResult( - REQUEST_KEY_DOCUMENT_SCAN, - bundleOf(KEY_SCAN_TYPE_FRONT to result.output) - ) - findNavController().navigateUp() - } - - scanType!!.isBack -> { - setFragmentResult( - REQUEST_KEY_DOCUMENT_SCAN, - bundleOf(KEY_SCAN_TYPE_BACK to result.output) - ) - findNavController().navigateUp() - } - } - } - - binding.buttonReset.setOnClickListener { - resetUI() - binding.viewScanResults.visibility = View.GONE - binding.viewScan.visibility = View.VISIBLE - documentScanViewModel.resetScanDispositions() - val verification = binding.buttonReset.tag as Verification - startScan(scanType!!, verification.capture) - binding.viewCamera.startAnalyzer() - } - - identityViewModel.observeForVerificationResults( - viewLifecycleOwner, - onError = {}, - onSuccess = { - binding.buttonReset.tag = it - initiateScanner(it) - } - ) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun initiateScanner(verification: Verification) { - identityViewModel.documentDetectorModelFile.observe(viewLifecycleOwner) { - if (it != null) { - documentScanViewModel.initialize( - it, - verification.capture.models.document.threshold - ) - - startScan(scanType!!, verification.capture) - } - } - } - - private fun startScan( - scanType: ScanDisposition.DocumentScanType, - capture: VerificationCapture - ) { - documentScanViewModel.scanner?.scan(binding.viewCamera, scanType, capture, requireContext().getRenderScript()) - } - - private fun resetUI() { - when { - scanType!!.isFront -> { - binding.tvScanDocumentSide.text = getString( - R.string.scan_capture_text_document_side_front, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - } - - scanType!!.isBack -> { - binding.tvScanDocumentSide.text = getString( - R.string.scan_capture_text_document_side_back, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - } - } - - binding.tvScanMessage.text = getString( - R.string.scan_capture_text_scan_message, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - } - - private fun updateUI(result: ScanResult?) { - when (result?.disposition) { - is ScanDisposition.Start -> { - resetUI() - } - - is ScanDisposition.Detected -> { - binding.tvScanMessage.text = getString(R.string.scan_capture_text_document_detected) - } - - is ScanDisposition.Desired -> { - binding.tvScanMessage.text = - getString(R.string.scan_capture_text_document_scan_completed) - } - - is ScanDisposition.Undesired -> {} - is ScanDisposition.Completed -> {} - is ScanDisposition.Timeout, null -> { - // noOP - } - } - } - - private fun onVerificationPage() { - documentScanViewModel.documentScanCompleteDisposition.observe(viewLifecycleOwner) { - - if (it.disposition is ScanDisposition.Completed) { - // stop the analyzer - documentScanViewModel.scanner?.stopScan(binding.viewCamera) - binding.buttonContinue.tag = it - binding.buttonContinue.isEnabled = true - - binding.viewScan.visibility = View.GONE - binding.viewScanResults.visibility = View.VISIBLE - val output = it.output as DocumentDetectionOutput - val bitmap = output.bitmap - - reportSuccessfulScanTelemetry(it.disposition as ScanDisposition.Completed, output) - - binding.ivScan.setImageBitmap(bitmap) - } else if (it.disposition is ScanDisposition.Timeout) { - - identityViewModel.reportTelemetry( - identityViewModel - .analyticsRequestBuilder - .documentScanTimeOut(scanType = (it.disposition as ScanDisposition.Timeout).type) - ) - - documentScanViewModel.scanner?.stopScan(binding.viewCamera) - findNavController().navigate(R.id.action_global_fragment_scan_capture_error) - } - } - } - - private fun reportSuccessfulScanTelemetry(scanDisposition: ScanDisposition, output: DocumentDetectionOutput) { - val telemetryDisposition = if (scanDisposition.type.isFront) { - AnalyticsDisposition(frontModelScore = output.score, scanType = scanDisposition.type) - } else { - AnalyticsDisposition(backModelScore = output.score, scanType = scanDisposition.type) - } - - identityViewModel.modifyAnalyticsDisposition(disposition = telemetryDisposition) - } - - internal companion object { - internal const val KEY_DOCUMENT_SCAN_TYPE = ":scan-type" - internal const val KEY_SCAN_TYPE_FRONT = ":front" - internal const val KEY_SCAN_TYPE_BACK = ":back" - internal const val REQUEST_KEY_DOCUMENT_SCAN = ":scan" - - internal fun IdentityDocumentType.getScanType(): - Pair { - return when (this) { - IdentityDocumentType.IDENTITY_CARD -> { - Pair( - ScanDisposition.DocumentScanType.ID_FRONT, - ScanDisposition.DocumentScanType.ID_BACK - ) - } - - IdentityDocumentType.PASSPORT -> { - Pair( - ScanDisposition.DocumentScanType.PASSPORT, - null - ) - } - - IdentityDocumentType.DRIVING_LICENSE -> { - Pair( - ScanDisposition.DocumentScanType.DL_FRONT, - ScanDisposition.DocumentScanType.DL_BACK - ) - } - } - } - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/scan/ScanCaptureSideFragment.kt b/identity/src/main/java/io/falu/identity/capture/scan/ScanCaptureSideFragment.kt deleted file mode 100644 index 576b6246..00000000 --- a/identity/src/main/java/io/falu/identity/capture/scan/ScanCaptureSideFragment.kt +++ /dev/null @@ -1,168 +0,0 @@ -package io.falu.identity.capture.scan - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResultListener -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.NavController -import androidx.navigation.fragment.findNavController -import io.falu.identity.R -import io.falu.identity.ai.DocumentDetectionOutput -import io.falu.identity.api.DocumentUploadDisposition -import io.falu.identity.api.models.DocumentSide -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.capture.AbstractCaptureFragment -import io.falu.identity.capture.scan.ScanCaptureFragment.Companion.getScanType -import io.falu.identity.scan.ScanDisposition -import io.falu.identity.databinding.FragmentCaptureSideBinding -import io.falu.identity.documents.DocumentSelectionFragment -import io.falu.identity.utils.parcelable - -internal class ScanCaptureSideFragment(identityViewModelFactory: ViewModelProvider.Factory) : - AbstractCaptureFragment(identityViewModelFactory) { - - private var _binding: FragmentCaptureSideBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentCaptureSideBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.cardDocumentBack.visibility = - if (isPassport) View.GONE else View.VISIBLE - - binding.buttonContinue.isEnabled = false - - binding.tvScanTitle.text = - getString( - R.string.scan_capture_title_scan, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - binding.tvCardFront.text = - getString( - R.string.upload_document_capture_document_font, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - binding.tvCardBack.text = - getString( - R.string.upload_document_capture_document_back, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - - binding.buttonContinue.text = getString(R.string.button_continue) - - binding.buttonScanFront.setOnClickListener { - findNavController().navigateWithDocumentAndScanType( - identityDocumentType, - identityDocumentType.getScanType().first - ) - } - - binding.buttonScanBack.setOnClickListener { - findNavController().navigateWithDocumentAndScanType( - identityDocumentType, - identityDocumentType.getScanType().second - ) - } - - setFragmentResultListener(ScanCaptureFragment.REQUEST_KEY_DOCUMENT_SCAN) { _, bundle -> - if (bundle.containsKey(ScanCaptureFragment.KEY_SCAN_TYPE_FRONT)) { - val frontResult = - bundle.parcelable(ScanCaptureFragment.KEY_SCAN_TYPE_FRONT)!! - uploadScannedDocument(frontResult, DocumentSide.FRONT) - return@setFragmentResultListener - } - - if (bundle.containsKey(ScanCaptureFragment.KEY_SCAN_TYPE_BACK)) { - val backResult = - bundle.parcelable(ScanCaptureFragment.KEY_SCAN_TYPE_BACK)!! - uploadScannedDocument(backResult, DocumentSide.BACK) - } - } - - binding.buttonContinue.text = getString(R.string.button_continue) - binding.buttonContinue.setOnClickListener { - binding.buttonContinue.showProgress() - val disposition = binding.buttonContinue.tag as DocumentUploadDisposition - - updateVerificationAndAttemptDocumentSubmission( - source = R.id.fragment_scan_capture_side, - disposition.generateVerificationUploadRequest(identityDocumentType) - ) - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun showDocumentFrontUploading() { - binding.buttonScanFront.visibility = View.GONE - binding.progressScanFront.visibility = View.VISIBLE - binding.ivFrontScanned.visibility = View.GONE - } - - override fun showDocumentBackUploading() { - binding.buttonScanBack.visibility = View.GONE - binding.progressScanBack.visibility = View.VISIBLE - binding.ivBackScanned.visibility = View.GONE - } - - override fun showDocumentFrontDoneUploading(disposition: DocumentUploadDisposition) { - binding.buttonScanFront.visibility = View.GONE - binding.progressScanFront.visibility = View.GONE - binding.ivFrontScanned.visibility = View.VISIBLE - - if (identityDocumentType == IdentityDocumentType.PASSPORT) { - binding.buttonContinue.isEnabled = true - binding.buttonContinue.tag = disposition - } - } - - override fun showDocumentBackDoneUploading() { - binding.buttonScanBack.visibility = View.GONE - binding.progressScanBack.visibility = View.GONE - binding.ivBackScanned.visibility = View.VISIBLE - } - - override fun showBothSidesUploaded(disposition: DocumentUploadDisposition) { - binding.buttonContinue.isEnabled = true - binding.buttonContinue.tag = disposition - } - - override fun resetViews(documentSide: DocumentSide) { - if (documentSide == DocumentSide.FRONT) { - binding.buttonScanFront.visibility = View.VISIBLE - binding.progressScanFront.visibility = View.GONE - binding.ivFrontScanned.visibility = View.GONE - } else { - binding.buttonScanBack.visibility = View.VISIBLE - binding.progressScanBack.visibility = View.GONE - binding.ivBackScanned.visibility = View.GONE - } - } - - internal companion object { - private fun NavController.navigateWithDocumentAndScanType( - identityDocumentType: IdentityDocumentType, - scanType: ScanDisposition.DocumentScanType? - ) { - val bundle = bundleOf( - DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE to identityDocumentType, - ScanCaptureFragment.KEY_DOCUMENT_SCAN_TYPE to scanType - ) - navigate(R.id.action_fragment_scan_capture_side_to_fragment_scan_capture, bundle) - } - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/upload/UploadCaptureFragment.kt b/identity/src/main/java/io/falu/identity/capture/upload/UploadCaptureFragment.kt deleted file mode 100644 index f5089eb1..00000000 --- a/identity/src/main/java/io/falu/identity/capture/upload/UploadCaptureFragment.kt +++ /dev/null @@ -1,145 +0,0 @@ -package io.falu.identity.capture.upload - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.ViewModelProvider -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_UPLOAD_CAPTURE -import io.falu.identity.api.DocumentUploadDisposition -import io.falu.identity.api.models.DocumentSide -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.UploadMethod -import io.falu.identity.capture.AbstractCaptureFragment -import io.falu.identity.capture.scan.ScanCaptureFragment.Companion.getScanType -import io.falu.identity.databinding.FragmentUploadCaptureBinding - -internal class UploadCaptureFragment(identityViewModelFactory: ViewModelProvider.Factory) : - AbstractCaptureFragment(identityViewModelFactory) { - private var _binding: FragmentUploadCaptureBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentUploadCaptureBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - identityViewModel.reportTelemetry( - identityViewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_UPLOAD_CAPTURE) - ) - - binding.cardDocumentBack.visibility = - if (isPassport) View.GONE else View.VISIBLE - binding.buttonContinue.isEnabled = false - - binding.tvUploadTitle.text = - getString( - R.string.upload_document_capture_title, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - binding.tvCardFront.text = - getString( - R.string.upload_document_capture_document_font, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - binding.tvCardBack.text = - getString( - R.string.upload_document_capture_document_back, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - - captureDocumentViewModel.pickDocumentImages( - fragment = this, - onFrontImagePicked = { - analyze(uri = it, identityDocumentType.getScanType().first, DocumentSide.FRONT, UploadMethod.UPLOAD) - }, - onBackImagePicked = { - identityDocumentType.getScanType().second?.let { scanType -> - analyze( - uri = it, - scanType, - DocumentSide.BACK, - UploadMethod.UPLOAD - ) - } - } - ) - - binding.buttonSelectFront.setOnClickListener { - captureDocumentViewModel.pickImageFront() - } - - binding.buttonSelectBack.setOnClickListener { - captureDocumentViewModel.pickImageBack() - } - - binding.buttonContinue.text = getString(R.string.button_continue) - binding.buttonContinue.setOnClickListener { - binding.buttonContinue.showProgress() - val disposition = binding.buttonContinue.tag as DocumentUploadDisposition - updateVerificationAndAttemptDocumentSubmission( - source = R.id.action_fragment_document_capture_methods_to_fragment_upload_capture, - disposition.generateVerificationUploadRequest(identityDocumentType) - ) - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun showDocumentFrontUploading() { - binding.buttonSelectFront.visibility = View.GONE - binding.progressSelectFront.visibility = View.VISIBLE - binding.ivFrontUploaded.visibility = View.GONE - } - - override fun showDocumentBackUploading() { - binding.buttonSelectBack.visibility = View.GONE - binding.progressSelectBack.visibility = View.VISIBLE - binding.ivBackUploaded.visibility = View.GONE - } - - override fun showDocumentFrontDoneUploading(disposition: DocumentUploadDisposition) { - binding.buttonSelectFront.visibility = View.GONE - binding.progressSelectFront.visibility = View.GONE - binding.ivFrontUploaded.visibility = View.VISIBLE - - if (identityDocumentType == IdentityDocumentType.PASSPORT) { - binding.buttonContinue.isEnabled = true - binding.buttonContinue.tag = disposition - } - } - - override fun showDocumentBackDoneUploading() { - binding.buttonSelectBack.visibility = View.GONE - binding.progressSelectBack.visibility = View.GONE - binding.ivBackUploaded.visibility = View.VISIBLE - } - - override fun resetViews(documentSide: DocumentSide) { - if (documentSide == DocumentSide.FRONT) { - binding.buttonSelectFront.visibility = View.VISIBLE - binding.progressSelectFront.visibility = View.GONE - binding.ivFrontUploaded.visibility = View.GONE - } else { - binding.buttonSelectBack.visibility = View.VISIBLE - binding.progressSelectBack.visibility = View.GONE - binding.ivBackUploaded.visibility = View.GONE - } - } - - override fun showBothSidesUploaded(disposition: DocumentUploadDisposition) { - binding.buttonContinue.isEnabled = true - binding.buttonContinue.tag = disposition - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/confirmation/ConfirmationFragment.kt b/identity/src/main/java/io/falu/identity/confirmation/ConfirmationFragment.kt deleted file mode 100644 index 0837c4e0..00000000 --- a/identity/src/main/java/io/falu/identity/confirmation/ConfirmationFragment.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.falu.identity.confirmation - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import io.falu.identity.IdentityVerificationResult -import io.falu.identity.IdentityVerificationResultCallback -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.databinding.FragmentConfirmationBinding - -internal class ConfirmationFragment( - private val factory: ViewModelProvider.Factory, - val callback: IdentityVerificationResultCallback -) : Fragment() { - private var _binding: FragmentConfirmationBinding? = null - private val binding get() = _binding!! - - private val viewModel: IdentityVerificationViewModel by activityViewModels { factory } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentConfirmationBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.buttonFinish.setOnClickListener { - viewModel.reportSuccessfulVerificationTelemetry() - callback.onFinishWithResult(IdentityVerificationResult.Succeeded) - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/countries/CountriesAdapter.kt b/identity/src/main/java/io/falu/identity/countries/CountriesAdapter.kt deleted file mode 100644 index 8de71f2a..00000000 --- a/identity/src/main/java/io/falu/identity/countries/CountriesAdapter.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.falu.identity.countries - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import coil.decode.SvgDecoder -import coil.load -import io.falu.identity.api.models.country.SupportedCountry -import io.falu.identity.databinding.ListItemCountriesBinding - -internal class CountriesAdapter(context: Context, layoutId: Int, private val countries: List) : - ArrayAdapter(context, layoutId) { - - override fun getCount(): Int { - return countries.size - } - - override fun getItem(position: Int): SupportedCountry? { - return countries.getOrNull(position) - } - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val binding: ListItemCountriesBinding - val view: View - - if (convertView == null) { - val inflater = LayoutInflater.from(context) - binding = ListItemCountriesBinding.inflate(inflater, parent, false) - view = binding.root - view.tag = binding - } else { - view = convertView - binding = view.tag as ListItemCountriesBinding - } - - val country = getItem(position) - - if (country != null) { - binding.ivFlag.load(country.country.flag) { - decoderFactory(SvgDecoder.Factory()) - } - binding.tvCountry.text = country.country.name - } - - return view - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/documents/DocumentCaptureMethodsFragment.kt b/identity/src/main/java/io/falu/identity/documents/DocumentCaptureMethodsFragment.kt deleted file mode 100644 index 4e5348ca..00000000 --- a/identity/src/main/java/io/falu/identity/documents/DocumentCaptureMethodsFragment.kt +++ /dev/null @@ -1,163 +0,0 @@ -package io.falu.identity.documents - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.IdRes -import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.AnalyticsDisposition -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_DOCUMENT_CAPTURE_METHODS -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.UploadMethod -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.camera.CameraPermissionsFragment -import io.falu.identity.databinding.FragmentDocumentCaptureMethodsBinding - -internal class DocumentCaptureMethodsFragment(private val factory: ViewModelProvider.Factory) : - CameraPermissionsFragment() { - - private var _binding: FragmentDocumentCaptureMethodsBinding? = null - private val binding get() = _binding!! - - private val viewModel: IdentityVerificationViewModel by activityViewModels { factory } - private lateinit var identityDocumentType: IdentityDocumentType - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentDocumentCaptureMethodsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewModel.reportTelemetry( - viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_DOCUMENT_CAPTURE_METHODS) - ) - - identityDocumentType = - (requireArguments().getSerializable(DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE) - as IdentityDocumentType?)!! - - binding.tvDocumentCaptureMethod.text = - getString( - R.string.document_capture_method_subtitle, - identityDocumentType.getIdentityDocumentName(requireContext()) - ) - - binding.viewCaptureMethodScan.setOnClickListener { - viewModel.resetDocumentUploadDisposition() - reportUploadMethodTelemetry(UploadMethod.AUTO) - checkCameraPermissions(identityDocumentType.toScanCaptureDestination()) - } - - binding.viewCaptureMethodPhoto.setOnClickListener { - viewModel.resetDocumentUploadDisposition() - reportUploadMethodTelemetry(UploadMethod.MANUAL) - checkCameraPermissions(identityDocumentType.toManualCaptureDestination()) - } - - binding.viewCaptureMethodUpload.setOnClickListener { - viewModel.resetDocumentUploadDisposition() - reportUploadMethodTelemetry(UploadMethod.UPLOAD) - navigateToDestination(identityDocumentType.toUploadCaptureDestination()) - } - - viewModel.observeForVerificationResults( - viewLifecycleOwner, - onSuccess = { onVerificationSuccessful(it) }, - onError = {} - ) - } - - /** - * - */ - private fun reportUploadMethodTelemetry(uploadMethod: UploadMethod) { - viewModel.modifyAnalyticsDisposition(disposition = AnalyticsDisposition(uploadMethod = uploadMethod)) - } - - /** - * - */ - private fun navigateToDestination(@IdRes destinationId: Int) { - val bundle = - bundleOf(DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE to identityDocumentType) - findNavController().navigate(destinationId, bundle) - } - - /** - * - */ - private fun onCameraPermissionGranted(@IdRes destinationId: Int) { - navigateToDestination(destinationId) - viewModel.reportTelemetry(viewModel.analyticsRequestBuilder.cameraPermissionGranted(identityDocumentType)) - } - - /** - * - */ - private fun onCameraPermissionDenied() { - viewModel.reportTelemetry(viewModel.analyticsRequestBuilder.cameraPermissionDenied(identityDocumentType)) - } - - /** - * - */ - private fun checkCameraPermissions(@IdRes destinationId: Int) { - requestCameraPermissions( - onCameraPermissionGranted = { onCameraPermissionGranted(destinationId) }, - onCameraPermissionDenied = { onCameraPermissionDenied() } - ) - } - - /** - * - */ - private fun onVerificationSuccessful(verification: Verification) { - val allowUploads = verification.options.allowUploads - binding.viewCaptureMethodUpload.visibility = - if (allowUploads) View.VISIBLE else View.GONE - } - - internal companion object { - @IdRes - private fun IdentityDocumentType.toUploadCaptureDestination() = - when (this) { - IdentityDocumentType.IDENTITY_CARD, - IdentityDocumentType.PASSPORT, - IdentityDocumentType.DRIVING_LICENSE -> - R.id.action_fragment_document_capture_methods_to_fragment_upload_capture - } - - @IdRes - private fun IdentityDocumentType.toManualCaptureDestination() = - when (this) { - IdentityDocumentType.IDENTITY_CARD, - IdentityDocumentType.PASSPORT, - IdentityDocumentType.DRIVING_LICENSE -> - R.id.action_fragment_document_capture_methods_to_fragment_manual_capture - } - - private fun IdentityDocumentType.toScanCaptureDestination() = - when (this) { - IdentityDocumentType.IDENTITY_CARD, - IdentityDocumentType.PASSPORT, - IdentityDocumentType.DRIVING_LICENSE -> - R.id.action_fragment_document_capture_methods_to_fragment_scan_capture_side - } - - fun IdentityDocumentType.getIdentityDocumentName(context: Context) = - context.getString(this.titleRes) - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/documents/DocumentSelectionFragment.kt b/identity/src/main/java/io/falu/identity/documents/DocumentSelectionFragment.kt deleted file mode 100644 index 7ddb8be1..00000000 --- a/identity/src/main/java/io/falu/identity/documents/DocumentSelectionFragment.kt +++ /dev/null @@ -1,170 +0,0 @@ -package io.falu.identity.documents - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_DOCUMENT_SELECTION -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.country.SupportedCountry -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.api.models.verification.VerificationType -import io.falu.identity.api.models.verification.VerificationUpdateOptions -import io.falu.identity.countries.CountriesAdapter -import io.falu.identity.databinding.FragmentDocumentSelectionBinding -import io.falu.identity.utils.navigateToApiResponseProblemFragment -import io.falu.identity.utils.updateVerification - -class DocumentSelectionFragment(private val factory: ViewModelProvider.Factory) : Fragment() { - - private val viewModel: IdentityVerificationViewModel by activityViewModels { factory } - - private var _binding: FragmentDocumentSelectionBinding? = null - private val binding get() = _binding!! - - private var identityDocumentType: IdentityDocumentType? = null - - private var verification: Verification? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentDocumentSelectionBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewModel.reportTelemetry(viewModel.analyticsRequestBuilder - .screenPresented(screenName = SCREEN_NAME_DOCUMENT_SELECTION)) - - viewModel.observerForSupportedCountriesResults( - viewLifecycleOwner, - onSuccess = { onSupportedCountriesListed(it.toList()) }, - onError = { - navigateToApiResponseProblemFragment(it) - } - ) - - binding.buttonContinue.text = getString(R.string.button_continue) - binding.buttonContinue.isEnabled = false - binding.buttonContinue.setOnClickListener { - updateVerification() - } - - binding.groupDocumentTypes.setOnCheckedStateChangeListener { group, checkIds -> - binding.buttonContinue.isEnabled = checkIds.isNotEmpty() - - when (group.checkedChipId) { - R.id.chip_passport -> identityDocumentType = IdentityDocumentType.PASSPORT - R.id.chip_identity_card -> identityDocumentType = IdentityDocumentType.IDENTITY_CARD - R.id.chip_driving_license -> identityDocumentType = IdentityDocumentType.DRIVING_LICENSE - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - /** - * - */ - private fun onSupportedCountriesListed(countries: List) { - val countriesAdapter = CountriesAdapter(requireContext(), R.layout.list_item_countries, countries) - -// ArrayAdapter( -// requireContext(), -// R.layout.dropdown_menu_popup_item, -// countries.map { it.country.name }) - binding.inputIssuingCountry.setAdapter(countriesAdapter) - binding.inputIssuingCountry.setText(countriesAdapter.getItem(0)?.country?.name.orEmpty(), false) - - val country = getSupportedCountry(countries) - getVerificationResults(country) - - binding.inputIssuingCountry.setOnItemClickListener { _, _, position, _ -> - binding.inputIssuingCountry.setText(countriesAdapter.getItem(position)?.country?.name.orEmpty(), false) - getVerificationResults(country) - } - } - - /** - * - */ - private fun getSupportedCountry(countries: List): SupportedCountry { - val country = binding.inputIssuingCountry.text.toString() - return countries.first { it.country.name == country } - } - - /** - * - */ - private fun getVerificationResults(country: SupportedCountry) { - binding.buttonContinue.tag = country - viewModel.observeForVerificationResults( - viewLifecycleOwner, - onSuccess = { acceptedDocumentOptions(it, country) }, - onError = {} - ) - } - - /***/ - private fun updateVerification() { - verification?.let { updateVerification(it) } - } - - /***/ - private fun updateVerification(verification: Verification) { - binding.buttonContinue.showProgress() - val country = binding.buttonContinue.tag as SupportedCountry - val updateOptions = VerificationUpdateOptions(country = country.country.code) - - val action = if (verification.type != VerificationType.IDENTITY_NUMBER) { - R.id.action_fragment_document_selection_to_fragment_document_capture_methods - } else { - R.id.action_fragment_document_selection_to_fragment_identity_verification - } - - updateVerification( - viewModel, - updateOptions, - source = R.id.action_fragment_welcome_to_fragment_document_selection, - onSuccess = { - val bundle = bundleOf(KEY_IDENTITY_DOCUMENT_TYPE to identityDocumentType) - findNavController().navigate(action, bundle) - }) - } - - /** - * - */ - private fun acceptedDocumentOptions(verification: Verification, country: SupportedCountry) { - this.verification = verification - - val acceptedDocuments = - verification.options.document.allowed.toSet().intersect(country.documents.toSet()) - - binding.chipIdentityCard.isEnabled = - acceptedDocuments.contains(IdentityDocumentType.IDENTITY_CARD) - binding.chipPassport.isEnabled = - acceptedDocuments.contains(IdentityDocumentType.PASSPORT) - binding.chipDrivingLicense.isEnabled = - acceptedDocuments.contains(IdentityDocumentType.DRIVING_LICENSE) - } - - internal companion object { - const val KEY_IDENTITY_DOCUMENT_TYPE = ":document-type" - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/error/AbstractErrorFragment.kt b/identity/src/main/java/io/falu/identity/error/AbstractErrorFragment.kt deleted file mode 100644 index f29cc417..00000000 --- a/identity/src/main/java/io/falu/identity/error/AbstractErrorFragment.kt +++ /dev/null @@ -1,53 +0,0 @@ -package io.falu.identity.error - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import io.falu.identity.IdentityVerificationResultCallback -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_ERROR -import io.falu.identity.databinding.FragmentErrorBinding - -internal abstract class AbstractErrorFragment(identityViewModelFactory: ViewModelProvider.Factory) : Fragment() { - private var _binding: FragmentErrorBinding? = null - protected val binding get() = _binding!! - - private val identityViewModel: IdentityVerificationViewModel by activityViewModels { identityViewModelFactory } - - protected lateinit var callback: IdentityVerificationResultCallback - - override fun onAttach(context: Context) { - super.onAttach(context) - try { - callback = context as IdentityVerificationResultCallback - } catch (e: ClassCastException) { - throw ClassCastException("$context must implement ${IdentityVerificationResultCallback::class.java}") - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - _binding = FragmentErrorBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - identityViewModel.reportTelemetry( - identityViewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_ERROR) - ) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/error/ErrorFragment.kt b/identity/src/main/java/io/falu/identity/error/ErrorFragment.kt deleted file mode 100644 index 0eeed886..00000000 --- a/identity/src/main/java/io/falu/identity/error/ErrorFragment.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.falu.identity.error - -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.annotation.IdRes -import androidx.core.os.bundleOf -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.NavController -import androidx.navigation.fragment.findNavController -import io.falu.identity.IdentityVerificationResult -import io.falu.identity.R -import io.falu.identity.api.models.requirements.RequirementError -import io.falu.identity.api.models.requirements.RequirementError.Companion.canNavigateBackTo -import io.falu.identity.utils.getErrorDescription -import software.tingle.api.HttpApiResponseProblem - -internal class ErrorFragment(identityViewModelFactory: ViewModelProvider.Factory) : - AbstractErrorFragment(identityViewModelFactory) { - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val title = requireArguments().getString(KEY_ERROR_TITLE) - val desc = requireArguments().getString(KEY_ERROR_DESCRIPTION) - val cancel = requireArguments().getBoolean(KEY_CANCEL_FLOW, false) - val back = - requireArguments().getString(KEY_BACK_BUTTON_TEXT, getString(R.string.button_cancel)) - val throwable = requireArguments().getSerializable(KEY_ERROR_CAUSE) as Throwable - - binding.tvErrorTitle.text = title - binding.tvErrorDescription.text = desc - binding.tvErrorMessage.visibility = View.GONE - binding.buttonErrorActionPrimary.text = back - - // If cancel is `true` then terminate the workflow with an Exception - if (cancel) { - binding.buttonErrorActionPrimary.setOnClickListener { - callback.onFinishWithResult(IdentityVerificationResult.Failed(throwable)) - } - } else { - binding.buttonErrorActionPrimary.setOnClickListener { - val destination = requireArguments().getInt(KEY_BACK_BUTTON_DESTINATION) - if (destination == UNKNOWN_DESTINATION) { - findNavController().navigate(DESTINATION_WELCOME_FRAGMENT) - } else { - findNavController().navigateUp() - } - } - } - } - - internal companion object { - private const val KEY_ERROR_TITLE = ":error-title" - private const val KEY_ERROR_DESCRIPTION = ":error-description" - private const val KEY_BACK_BUTTON_DESTINATION = ":error-button-destination" - private const val KEY_BACK_BUTTON_TEXT = ":error-back-button-text" - private const val KEY_CANCEL_FLOW = ":error-cancel-flow" - private const val KEY_ERROR_CAUSE = ":error-cause" - - private const val UNKNOWN_DESTINATION = -1 - private val DESTINATION_WELCOME_FRAGMENT = - R.id.fragment_welcome - - internal fun NavController.navigateWithRequirementErrors( - context: Context, - @IdRes source: Int, - error: RequirementError - ) { - val bundle = bundleOf( - KEY_ERROR_TITLE to error.code, - KEY_ERROR_DESCRIPTION to error.description, - KEY_BACK_BUTTON_DESTINATION to if (error.canNavigateBackTo(source = source)) - source else UNKNOWN_DESTINATION, - KEY_BACK_BUTTON_TEXT to context.getString(R.string.button_rectify), - KEY_CANCEL_FLOW to false, - KEY_ERROR_CAUSE to Exception("Identity verification requirement error: ${error.description}") - ) - navigate(R.id.action_global_fragment_error, bundle) - } - - internal fun NavController.navigateWithApiExceptions( - context: Context, - error: HttpApiResponseProblem? - ) { - val desc = error?.getErrorDescription(context) - ?: context.getString(R.string.error_description_server) - val bundle = bundleOf( - KEY_ERROR_TITLE to context.getString(R.string.error_title), - KEY_ERROR_DESCRIPTION to desc, - KEY_CANCEL_FLOW to true, - KEY_ERROR_CAUSE to Exception("Api Exception: $error") - ) - navigate(R.id.action_global_fragment_error, bundle) - } - - internal fun NavController.navigateWithFailure(context: Context, throwable: Throwable) { - val bundle = bundleOf( - KEY_ERROR_TITLE to context.getString(R.string.error_title), - KEY_ERROR_DESCRIPTION to context.getString(R.string.error_title_unexpected_error), - KEY_CANCEL_FLOW to true, - KEY_ERROR_CAUSE to throwable - ) - navigate(R.id.action_global_fragment_error, bundle) - } - - internal fun NavController.navigateWithDepletedAttempts(context: Context) { - val bundle = bundleOf( - KEY_ERROR_TITLE to context.getString(R.string.error_title_depleted_attempts), - KEY_ERROR_DESCRIPTION to context.getString(R.string.error_description_depleted_attempts), - KEY_CANCEL_FLOW to true, - KEY_ERROR_CAUSE to Exception(context.getString(R.string.error_description_depleted_attempts)) - ) - - navigate(R.id.action_global_fragment_error, bundle) - } - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/error/ScanCaptureErrorFragment.kt b/identity/src/main/java/io/falu/identity/error/ScanCaptureErrorFragment.kt deleted file mode 100644 index e4d3b3f3..00000000 --- a/identity/src/main/java/io/falu/identity/error/ScanCaptureErrorFragment.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.falu.identity.error - -import android.os.Bundle -import android.view.View -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.identity.R - -internal class ScanCaptureErrorFragment(identityViewModelFactory: ViewModelProvider.Factory) : - AbstractErrorFragment(identityViewModelFactory) { - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.tvErrorTitle.text = getString(R.string.error_title_scan_capture) - binding.tvErrorDescription.text = getString(R.string.error_description_scan_capture) - binding.tvErrorMessage.text = getString(R.string.error_message_scan_capture) - - binding.buttonErrorActionPrimary.text = getString(R.string.button_try_again) - - binding.buttonErrorActionSecondary.visibility = View.VISIBLE - binding.buttonErrorActionSecondary.text = getString(R.string.button_change_method) - - binding.buttonErrorActionPrimary.setOnClickListener { - findNavController().navigateUp() - } - - binding.buttonErrorActionSecondary.setOnClickListener { - findNavController().popBackStack(R.id.fragment_document_capture_methods, true) - } - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/error/SelfieCaptureErrorFragment.kt b/identity/src/main/java/io/falu/identity/error/SelfieCaptureErrorFragment.kt deleted file mode 100644 index 122f63cc..00000000 --- a/identity/src/main/java/io/falu/identity/error/SelfieCaptureErrorFragment.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.falu.identity.error - -import android.os.Bundle -import android.view.View -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.identity.IdentityVerificationResult -import io.falu.identity.R - -internal class SelfieCaptureErrorFragment(identityViewModelFactory: ViewModelProvider.Factory) : - AbstractErrorFragment(identityViewModelFactory) { - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.tvErrorTitle.text = getString(R.string.error_title_selfie_capture) - binding.tvErrorDescription.text = getString(R.string.error_description_selfie_capture) - - binding.buttonErrorActionPrimary.text = getString(R.string.button_try_again) - - binding.buttonErrorActionSecondary.visibility = View.VISIBLE - binding.buttonErrorActionSecondary.text = getString(R.string.button_cancel) - - binding.buttonErrorActionPrimary.setOnClickListener { - findNavController().navigateUp() - } - - binding.buttonErrorActionSecondary.setOnClickListener { - callback.onFinishWithResult(IdentityVerificationResult.Canceled) - } - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/CameraPermissionDeniedDestination.kt b/identity/src/main/java/io/falu/identity/navigation/CameraPermissionDeniedDestination.kt new file mode 100644 index 00000000..122297bf --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/CameraPermissionDeniedDestination.kt @@ -0,0 +1,14 @@ +package io.falu.identity.navigation + +internal class CameraPermissionDeniedDestination : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + companion object { + const val CAMERA_PERMISSION_DENIED = "CameraPermissionDenied" + + val ROUTE = object : WorkflowRoute() { + override val base = CAMERA_PERMISSION_DENIED + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/ConfirmationDestination.kt b/identity/src/main/java/io/falu/identity/navigation/ConfirmationDestination.kt new file mode 100644 index 00000000..f62b22f4 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/ConfirmationDestination.kt @@ -0,0 +1,14 @@ +package io.falu.identity.navigation + +internal class ConfirmationDestination : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + internal companion object { + const val CONFIRMATION = "confirmation" + + val ROUTE = object : WorkflowRoute() { + override val base: String = CONFIRMATION + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/DocumentCaptureDestination.kt b/identity/src/main/java/io/falu/identity/navigation/DocumentCaptureDestination.kt new file mode 100644 index 00000000..b0cd6dd0 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/DocumentCaptureDestination.kt @@ -0,0 +1,32 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import io.falu.identity.api.models.IdentityDocumentType + +internal data class DocumentCaptureDestination(val documentType: IdentityDocumentType) : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + override val routeWithArgs: String = workflowRoute.withParameters( + KEY_DOCUMENT_TYPE to documentType + ) + + internal companion object { + const val CAPTURE_METHODS = "document_capture_methods" + internal const val KEY_DOCUMENT_TYPE = "document_type" + + internal fun identityDocumentType(entry: NavBackStackEntry) = + entry.getSerializable(KEY_DOCUMENT_TYPE)!! + + val ROUTE = object : WorkflowRoute() { + override val base: String = CAPTURE_METHODS + override val arguments = listOf( + navArgument(KEY_DOCUMENT_TYPE) { + type = NavType.EnumType(IdentityDocumentType::class.java) + } + ) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/DocumentSelectionDestination.kt b/identity/src/main/java/io/falu/identity/navigation/DocumentSelectionDestination.kt new file mode 100644 index 00000000..87d627ee --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/DocumentSelectionDestination.kt @@ -0,0 +1,14 @@ +package io.falu.identity.navigation + +internal class DocumentSelectionDestination : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + internal companion object { + const val DOCUMENT_SELECTION = "document_selection" + + val ROUTE = object : WorkflowRoute() { + override val base: String = DOCUMENT_SELECTION + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/DocumentVerificationDestination.kt b/identity/src/main/java/io/falu/identity/navigation/DocumentVerificationDestination.kt new file mode 100644 index 00000000..048ca357 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/DocumentVerificationDestination.kt @@ -0,0 +1,32 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import io.falu.identity.api.models.IdentityDocumentType + +internal class DocumentVerificationDestination(identityDocumentType: IdentityDocumentType) : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + override val routeWithArgs: String = workflowRoute.withParameters( + KEY_DOCUMENT_TYPE to identityDocumentType + ) + + internal companion object { + const val DOCUMENT_VERIFICATION = "document_verification" + const val KEY_DOCUMENT_TYPE = "document_type" + + internal fun identityDocumentType(entry: NavBackStackEntry) = + entry.getSerializable(KEY_DOCUMENT_TYPE)!! + + val ROUTE = object : WorkflowRoute() { + override val base: String = DOCUMENT_VERIFICATION + override val arguments = listOf( + navArgument(KEY_DOCUMENT_TYPE) { + type = NavType.EnumType(IdentityDocumentType::class.java) + } + ) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/ErrorDestination.kt b/identity/src/main/java/io/falu/identity/navigation/ErrorDestination.kt new file mode 100644 index 00000000..d2b5bf7e --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/ErrorDestination.kt @@ -0,0 +1,124 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import io.falu.identity.api.models.requirements.RequirementType + +internal data class ErrorDestination( + val title: String, + val desc: String = KEY_UNSET, + val message: String = KEY_UNSET, + val throwable: Throwable? = null, + val requirementType: RequirementType? = null, + val primaryButtonText: String = KEY_UNSET, + val backButtonText: String, + val backButtonDestination: String = KEY_UNSET, + val cancelFlow: Boolean = false +) : IdentityDestination() { + + override val workflowRoute = ROUTE + + override val routeWithArgs: String = workflowRoute.withParameters( + KEY_ERROR_TITLE to title, + KEY_ERROR_DESCRIPTION to desc, + KEY_ERROR_MESSAGE to message, + KEY_BACK_BUTTON_DESTINATION to backButtonDestination, + KEY_BACK_BUTTON_TEXT to backButtonText, + KEY_CANCEL_FLOW to cancelFlow, + KEY_PRIMARY_BUTTON_TEXT to primaryButtonText, + KEY_REQUIREMENT_TYPE to requirementType + ) + + internal companion object { + const val ERROR = "error" + internal const val KEY_ERROR_TITLE = "title" + internal const val KEY_ERROR_DESCRIPTION = "description" + internal const val KEY_ERROR_MESSAGE = "message" + internal const val KEY_ERROR_CAUSE = "cause" + internal const val KEY_BACK_BUTTON_DESTINATION = "buttonDestination" + internal const val KEY_BACK_BUTTON_TEXT = "backButtonText" + internal const val KEY_CANCEL_FLOW = "cancelFlow" + internal const val KEY_PRIMARY_BUTTON_TEXT = "primaryButtonText" + internal const val KEY_REQUIREMENT_TYPE = "requirementType" + internal const val KEY_UNSET = "unset" + + fun errorTitle(entry: NavBackStackEntry) = entry.getString(KEY_ERROR_TITLE) + + fun errorDescription(entry: NavBackStackEntry) = entry.getString(KEY_ERROR_DESCRIPTION) + + fun errorMessage(entry: NavBackStackEntry) = entry.getString(KEY_ERROR_MESSAGE).let { + if (it == KEY_UNSET) { + null + } else { + it + } + } + + fun errorCause(entry: NavBackStackEntry) = entry.getString(KEY_ERROR_CAUSE) + + fun backButtonDestination(entry: NavBackStackEntry) = entry.getString(KEY_BACK_BUTTON_DESTINATION).let { + if (it == KEY_UNSET) { + null + } else { + it + } + } + + fun backButtonText(entry: NavBackStackEntry) = entry.getString(KEY_BACK_BUTTON_TEXT).let { + if (it == KEY_UNSET) { + null + } else { + it + } + } + + fun cancelFlow(entry: NavBackStackEntry) = entry.getBoolean(KEY_CANCEL_FLOW) + + fun primaryButtonOptions(backStackEntry: NavBackStackEntry): Pair? { + val primaryButtonText = backStackEntry.getString(KEY_PRIMARY_BUTTON_TEXT) + val primaryButtonRequirementType: RequirementType? = + backStackEntry.getString(KEY_REQUIREMENT_TYPE) + .let { requirementString -> + if (requirementString.isNullOrEmpty()) { + null + } else { + RequirementType.valueOf(requirementString) + } + } + + return if (!primaryButtonText.isNullOrEmpty()) { + (primaryButtonText to primaryButtonRequirementType) + } else { + null + } + } + + val ROUTE = object : WorkflowRoute() { + override val base: String = ERROR + override val arguments = listOf( + navArgument(KEY_ERROR_TITLE) { + type = NavType.StringType + }, + navArgument(KEY_ERROR_DESCRIPTION) { + type = NavType.StringType + }, + navArgument(KEY_ERROR_MESSAGE) { + type = NavType.StringType + }, + navArgument(KEY_BACK_BUTTON_DESTINATION) { + type = NavType.StringType + }, + navArgument(KEY_BACK_BUTTON_TEXT) { + type = NavType.StringType + }, + navArgument(KEY_CANCEL_FLOW) { + type = NavType.BoolType + }, + navArgument(KEY_PRIMARY_BUTTON_TEXT) { + type = NavType.StringType + } + ) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/IdentityDestination.kt b/identity/src/main/java/io/falu/identity/navigation/IdentityDestination.kt new file mode 100644 index 00000000..1f726846 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/IdentityDestination.kt @@ -0,0 +1,70 @@ +package io.falu.identity.navigation + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import io.falu.identity.utils.serializable +import java.io.Serializable + +internal abstract class IdentityDestination(val popUpToParam: PopUpTo? = null) { + + internal abstract class WorkflowRoute { + abstract val base: String + open val arguments: List = emptyList() + + /** + * Navigation route for the screen, built using the base route and arguments: + * base?arg1={arg1}&arg2={arg2} + */ + val route: String + get() { + val argumentsString = arguments + .mapIndexed { index, argument -> + val separator = if (index == 0) "?" else "&" + "$separator${argument.name}={${argument.name}}" + } + .joinToString("") + + return "$base$argumentsString" + } + } + + abstract val workflowRoute: WorkflowRoute + + open val routeWithArgs: String + get() = workflowRoute.route +} + +internal fun IdentityDestination.WorkflowRoute.withParameters(vararg parameters: Pair): String { + var route = this.route + parameters.forEach { (key, value) -> + route = route.replace("{$key}", value.toString()) + } + return route +} + +internal data class PopUpTo( + val route: String, + val inclusive: Boolean +) + +internal fun NavController.navigateTo(destination: IdentityDestination) { + navigate(destination.routeWithArgs) { + destination.popUpToParam?.let { + popUpTo(it.route) { + inclusive = it.inclusive + } + } + } +} + +internal fun NavBackStackEntry?.getString(arg: String) = this?.arguments?.getString(arg) + +internal fun NavBackStackEntry?.getInt(arg: String) = + this?.arguments?.getInt(arg, 0) ?: 0 + +internal fun NavBackStackEntry?.getBoolean(arg: String) = + this?.arguments?.getBoolean(arg, false) ?: false + +internal inline fun NavBackStackEntry.getSerializable(arg: String) = + this.arguments?.serializable(arg) \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/IdentityNavigationGraph.kt b/identity/src/main/java/io/falu/identity/navigation/IdentityNavigationGraph.kt new file mode 100644 index 00000000..f8b48283 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/IdentityNavigationGraph.kt @@ -0,0 +1,265 @@ +package io.falu.identity.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import io.falu.identity.ContractArgs +import io.falu.identity.FallbackUrlCallback +import io.falu.identity.IdentityVerificationResult +import io.falu.identity.IdentityVerificationResultCallback +import io.falu.identity.R +import io.falu.identity.api.models.UploadMethod +import io.falu.identity.scan.ScanDisposition +import io.falu.identity.screens.ConfirmationScreen +import io.falu.identity.screens.DocumentCaptureMethodsScreen +import io.falu.identity.screens.DocumentSelectionScreen +import io.falu.identity.screens.DocumentVerificationScreen +import io.falu.identity.screens.InitialLoadingScreen +import io.falu.identity.screens.SupportScreen +import io.falu.identity.screens.TaxPinVerificationScreen +import io.falu.identity.screens.WelcomeScreen +import io.falu.identity.screens.capture.ManualCaptureScreen +import io.falu.identity.screens.capture.ScanCaptureScreen +import io.falu.identity.screens.capture.UploadCaptureScreen +import io.falu.identity.screens.error.ErrorScreen +import io.falu.identity.screens.error.ErrorScreenButton +import io.falu.identity.screens.selfie.SelfieScreen +import io.falu.identity.ui.IdentityVerificationBaseScreen +import io.falu.identity.utils.openAppSettings +import io.falu.identity.viewModel.DocumentScanViewModel +import io.falu.identity.viewModel.FaceScanViewModel +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun IdentityNavigationGraph( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController(), + identityViewModel: IdentityVerificationViewModel, + documentScanViewModel: DocumentScanViewModel, + faceScanViewModel: FaceScanViewModel, + verificationResultCallback: IdentityVerificationResultCallback, + contractArgs: ContractArgs, + fallbackUrlCallback: FallbackUrlCallback, + startDestination: String = InitialDestination.ROUTE.route, + navActions: IdentityVerificationNavActions = remember(navController) { + IdentityVerificationNavActions(navController) + }, + onNavControllerCreated: (NavController) -> Unit +) { + IdentityVerificationBaseScreen( + viewModel = identityViewModel, + contractArgs = contractArgs, + navigateToSupport = { navActions.navigateToSupport() } + ) { + val context = LocalContext.current + + LaunchedEffect(Unit) { + onNavControllerCreated(navController) + } + + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier + ) { + composable(InitialDestination.ROUTE.route) { + InitialLoadingScreen( + identityViewModel = identityViewModel, + navActions = navActions, + fallbackUrlCallback = fallbackUrlCallback + ) + } + + composable(SupportDestination.ROUTE.route) { + SupportScreen(identityViewModel = identityViewModel) + } + + composable(WelcomeDestination.ROUTE.route) { + WelcomeScreen( + viewModel = identityViewModel, + navActions = navActions, + verificationResultCallback = verificationResultCallback + ) + } + + composable(ConfirmationDestination.ROUTE.route) { + ConfirmationScreen(viewModel = identityViewModel, callback = verificationResultCallback) + } + + composable(DocumentSelectionDestination.ROUTE.route) { + DocumentSelectionScreen(viewModel = identityViewModel, navActions = navActions) + } + + composable(TaxPinDestination.ROUTE.route) { + TaxPinVerificationScreen(viewModel = identityViewModel, navActions = navActions) + } + + composable(SelfieDestination.ROUTE.route) { + SelfieScreen( + viewModel = identityViewModel, + faceScanViewModel = faceScanViewModel, + navActions = navActions + ) + } + + composable( + DocumentVerificationDestination.ROUTE.route, + arguments = DocumentVerificationDestination.ROUTE.arguments + ) { + DocumentVerificationScreen( + viewModel = identityViewModel, + navActions = navActions + ) + } + + composable( + DocumentCaptureDestination.ROUTE.route, + arguments = DocumentCaptureDestination.ROUTE.arguments + ) { entry -> + DocumentCaptureMethodsScreen( + identityViewModel, + DocumentCaptureDestination.identityDocumentType(entry), + navigateToCaptureMethod = { + when (it) { + UploadMethod.AUTO -> navActions.navigateToScanCapture( + documentType = DocumentCaptureDestination.identityDocumentType(entry) + ) + + UploadMethod.MANUAL -> navActions.navigateToManualCapture( + documentType = DocumentCaptureDestination.identityDocumentType(entry) + ) + + UploadMethod.UPLOAD -> navActions.navigateToUploadCapture( + documentType = DocumentCaptureDestination.identityDocumentType(entry) + ) + } + } + ) + } + + composable( + UploadCaptureDestination.ROUTE.route, + arguments = UploadCaptureDestination.ROUTE.arguments + ) { entry -> + UploadCaptureScreen( + viewModel = identityViewModel, + documentType = UploadCaptureDestination.identityDocumentType(entry), + navActions = navActions + ) + } + + composable( + ManualCaptureDestination.ROUTE.route, + arguments = ManualCaptureDestination.ROUTE.arguments + ) { entry -> + ManualCaptureScreen( + viewModel = identityViewModel, + documentType = ManualCaptureDestination.identityDocumentType(entry), + navActions = navActions + ) + } + + composable( + ScanCaptureDestination.ROUTE.route, + arguments = ScanCaptureDestination.ROUTE.arguments + ) { entry -> + ScanCaptureScreen( + viewModel = identityViewModel, + documentScanViewModel = documentScanViewModel, + documentType = ScanCaptureDestination.identityDocumentType(entry), + navActions = navActions + ) + } + + composable( + ErrorDestination.ROUTE.route, + arguments = ErrorDestination.ROUTE.arguments + ) { entry -> + ErrorScreen( + title = ErrorDestination.errorTitle(entry) ?: "", + desc = ErrorDestination.errorDescription(entry) ?: "", + message = ErrorDestination.errorMessage(entry), + primaryButton = ErrorDestination.primaryButtonOptions(entry) + ?.let { (buttonText, buttonRequirement) -> + ErrorScreenButton( + text = buttonText, + onClick = { + if (buttonRequirement != null) { + // NOOP + } else { + navController.resetAndNavigateUp(identityViewModel) + } + } + ) + }, + secondaryButton = ErrorScreenButton( + text = ErrorDestination.backButtonText(entry) ?: stringResource(R.string.button_cancel), + onClick = { + if (ErrorDestination.cancelFlow(entry)) { + verificationResultCallback.onFinishWithResult(IdentityVerificationResult.Canceled) + } else { + val destination = ErrorDestination.backButtonDestination(entry) + if (destination.isNullOrEmpty()) { + navActions.navigateToWelcome() + } else { + var shouldContinueNavigateUp = true + while ( + shouldContinueNavigateUp && + navController.currentDestination?.route?.substringBefore("?") != + destination + ) { + shouldContinueNavigateUp = navController.resetAndNavigateUp(identityViewModel) + } + } + } + } + ) + ) + } + + composable(CameraPermissionDeniedDestination.ROUTE.route) { + ErrorScreen( + title = stringResource(R.string.permission_explanation_title), + desc = stringResource(R.string.permission_explanation_camera), + primaryButton = ErrorScreenButton( + text = stringResource(R.string.button_app_settings), + onClick = { context.openAppSettings() } + ) + ) + } + + composable( + ScanTimeoutDestination.ROUTE.route, + arguments = ScanTimeoutDestination.ROUTE.arguments + ) { entry -> + val scanType = ScanTimeoutDestination.scanType(entry) + + ErrorScreen( + title = context.getString(R.string.error_title_scan_capture), + desc = context.getString(R.string.error_description_scan_capture), + message = if (scanType == ScanDisposition.DocumentScanType.SELFIE) { + context.getString(R.string.error_description_selfie_capture) + } else { + context.getString(R.string.error_message_scan_capture) + }, + secondaryButton = ErrorScreenButton( + text = stringResource(R.string.button_try_again), + onClick = { + if (scanType != null) { + navController.navigateTo(scanType.toScanDestination()) + } + } + ) + ) + } + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/IdentityVerificationNavActions.kt b/identity/src/main/java/io/falu/identity/navigation/IdentityVerificationNavActions.kt new file mode 100644 index 00000000..7b47da90 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/IdentityVerificationNavActions.kt @@ -0,0 +1,73 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavController +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.requirements.RequirementError +import io.falu.identity.scan.ScanDisposition + +internal class IdentityVerificationNavActions(private val navController: NavController) { + + fun navigateToWelcome() { + navController.navigateTo(WelcomeDestination()) + } + + fun navigateToSupport() { + navController.navigateTo(SupportDestination()) + } + + fun navigateToDocumentSelection() { + navController.navigateTo(DocumentSelectionDestination()) + } + + fun navigateToConfirmation() { + navController.navigateTo(ConfirmationDestination()) + } + + fun navigateToDocumentCaptureMethods(documentType: IdentityDocumentType) { + navController.navigateTo(DocumentCaptureDestination(documentType)) + } + + fun navigateToScanCapture(documentType: IdentityDocumentType) { + navController.navigateTo(ScanCaptureDestination(documentType)) + } + + fun navigateToManualCapture(documentType: IdentityDocumentType) { + navController.navigateTo(ManualCaptureDestination(documentType)) + } + + fun navigateToUploadCapture(documentType: IdentityDocumentType) { + navController.navigateTo(UploadCaptureDestination(documentType)) + } + + fun navigateToSelfie() { + navController.navigateTo(SelfieDestination()) + } + + fun navigateToErrorWithRequirementErrors(fromRoute: String, error: RequirementError) { + navController.navigateWithRequirementErrors(fromRoute, error) + } + + fun navigateToErrorWithApiExceptions(throwable: Throwable?) { + navController.navigateToErrorWithApiExceptions(throwable) + } + + fun navigateToErrorWithFailure(throwable: Throwable?) { + navController.navigateToErrorWithFailure(throwable) + } + + fun navigateToErrorWithScreenTimeout(scanType: ScanDisposition.DocumentScanType?) { + navController.navigateTo(ScanTimeoutDestination(scanType)) + } + + fun navigateToCameraPermissionDenied() { + navController.navigateTo(CameraPermissionDeniedDestination()) + } + + fun navigateToDocumentVerification(documentType: IdentityDocumentType) { + navController.navigateTo(DocumentVerificationDestination(documentType)) + } + + fun navigateToTaxPin() { + navController.navigateTo(TaxPinDestination()) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/InitialDestination.kt b/identity/src/main/java/io/falu/identity/navigation/InitialDestination.kt new file mode 100644 index 00000000..be96c2e9 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/InitialDestination.kt @@ -0,0 +1,14 @@ +package io.falu.identity.navigation + +internal class InitialDestination : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + internal companion object { + const val INITIAL = "initial" + + val ROUTE = object : WorkflowRoute() { + override val base: String = INITIAL + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/ManualCaptureDestination.kt b/identity/src/main/java/io/falu/identity/navigation/ManualCaptureDestination.kt new file mode 100644 index 00000000..0dc9d45f --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/ManualCaptureDestination.kt @@ -0,0 +1,32 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import io.falu.identity.api.models.IdentityDocumentType + +internal data class ManualCaptureDestination(val documentType: IdentityDocumentType) : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + override val routeWithArgs: String = workflowRoute.withParameters( + KEY_DOCUMENT_TYPE to documentType + ) + + internal companion object { + const val MANUAL_CAPTURE = "document_capture_method_manual" + internal const val KEY_DOCUMENT_TYPE = "document_type" + + internal fun identityDocumentType(entry: NavBackStackEntry) = + entry.getSerializable(KEY_DOCUMENT_TYPE)!! + + val ROUTE = object : WorkflowRoute() { + override val base: String = MANUAL_CAPTURE + override val arguments = listOf( + navArgument(KEY_DOCUMENT_TYPE) { + type = NavType.EnumType(IdentityDocumentType::class.java) + } + ) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/NavigationControllerExt.kt b/identity/src/main/java/io/falu/identity/navigation/NavigationControllerExt.kt new file mode 100644 index 00000000..13970fcb --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/NavigationControllerExt.kt @@ -0,0 +1,91 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavController +import io.falu.core.exceptions.ApiException +import io.falu.identity.R +import io.falu.identity.api.models.requirements.RequirementError +import io.falu.identity.api.models.requirements.RequirementType.Companion.matchesFromRoute +import io.falu.identity.utils.getErrorDescription +import io.falu.identity.viewModel.IdentityVerificationViewModel + +internal fun NavController.navigateWithRequirementErrors( + route: String, + requirementError: RequirementError +) { + navigateTo( + ErrorDestination( + title = requirementError.code, + desc = requirementError.description, + backButtonText = context.getString(R.string.button_rectify), + requirementType = requirementError.requirement, + backButtonDestination = if (requirementError.requirement!!.matchesFromRoute(route)) { + route + } else { + "" + } + ) + ) +} + +internal fun NavController.navigateToErrorWithApiExceptions(throwable: Throwable?) { + val error = (throwable as ApiException).problem + + navigateTo( + ErrorDestination( + title = context.getString(R.string.error_title), + desc = context.getString(R.string.error_title_verification_data_mismatch), + message = error?.getErrorDescription(context) ?: context.getString(R.string.error_description_server), + primaryButtonText = context.getString(R.string.button_rectify), + backButtonText = context.getString(R.string.button_cancel), + throwable = throwable, + cancelFlow = true + ) + ) +} + +internal fun NavController.navigateToErrorWithFailure(throwable: Throwable?) { + navigateTo( + ErrorDestination( + title = context.getString(R.string.error_title), + desc = context.getString(R.string.error_title_unexpected_error), + backButtonText = context.getString(R.string.button_cancel), + throwable = throwable, + cancelFlow = true + ) + ) +} + +internal fun NavController.navigateWithDepletedAttempts() { + navigateTo( + ErrorDestination( + title = context.getString(R.string.error_title_depleted_attempts), + desc = context.getString(R.string.error_description_depleted_attempts), + cancelFlow = true, + throwable = Exception(context.getString(R.string.error_description_depleted_attempts)), + backButtonText = context.getString(R.string.button_cancel) + ) + ) +} + +private val DOCUMENT_UPLOAD_ROUTES = setOf( + SelfieDestination.ROUTE.route, + ScanCaptureDestination.ROUTE.route, + ManualCaptureDestination.ROUTE.route, + UploadCaptureDestination.ROUTE.route +) + +internal fun NavController.resetAndNavigateUp(identityViewModel: IdentityVerificationViewModel): Boolean { + currentBackStackEntry?.destination?.route?.let { currentEntryRoute -> + if (DOCUMENT_UPLOAD_ROUTES.contains(currentEntryRoute)) { + identityViewModel.resetDocumentUploadDisposition() + } + } + + previousBackStackEntry?.destination?.route?.let { previousEntryRoute -> + if (DOCUMENT_UPLOAD_ROUTES.contains(previousEntryRoute)) { + identityViewModel.resetDocumentUploadDisposition() + } + } + + return navigateUp() +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/ScanCaptureDestination.kt b/identity/src/main/java/io/falu/identity/navigation/ScanCaptureDestination.kt new file mode 100644 index 00000000..5ade7af5 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/ScanCaptureDestination.kt @@ -0,0 +1,44 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import io.falu.identity.api.models.IdentityDocumentType + +internal data class ScanCaptureDestination( + val documentType: IdentityDocumentType, + val popToCapture: Boolean = false +) : IdentityDestination( + popUpToParam = if (popToCapture) { + PopUpTo( + route = DocumentCaptureDestination.ROUTE.route, + inclusive = false + ) + } else { + null + } +) { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + override val routeWithArgs: String = workflowRoute.withParameters( + KEY_DOCUMENT_TYPE to documentType + ) + + internal companion object { + const val SCAN_CAPTURE = "document_capture_method_scan" + internal const val KEY_DOCUMENT_TYPE = "document_type" + + internal fun identityDocumentType(entry: NavBackStackEntry) = + entry.getSerializable(KEY_DOCUMENT_TYPE)!! + + val ROUTE = object : WorkflowRoute() { + override val base: String = SCAN_CAPTURE + override val arguments = listOf( + navArgument(KEY_DOCUMENT_TYPE) { + type = NavType.EnumType(IdentityDocumentType::class.java) + } + ) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/ScanTimeoutDestination.kt b/identity/src/main/java/io/falu/identity/navigation/ScanTimeoutDestination.kt new file mode 100644 index 00000000..486ae2d4 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/ScanTimeoutDestination.kt @@ -0,0 +1,31 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import io.falu.identity.scan.ScanDisposition + +internal class ScanTimeoutDestination(scanType: ScanDisposition.DocumentScanType?) : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + override val routeWithArgs: String = workflowRoute.withParameters( + KEY_SCAN_TYPE to scanType + ) + + internal companion object { + const val SCAN_TIMEOUT = "scan_timeout" + internal const val KEY_SCAN_TYPE = "scan_type" + + internal fun scanType(entry: NavBackStackEntry) = + entry.getSerializable(KEY_SCAN_TYPE) + + val ROUTE = object : WorkflowRoute() { + override val base: String = SCAN_TIMEOUT + override val arguments = listOf( + navArgument(KEY_SCAN_TYPE) { + type = NavType.EnumType(ScanDisposition.DocumentScanType::class.java) + } + ) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/SelfieDestination.kt b/identity/src/main/java/io/falu/identity/navigation/SelfieDestination.kt new file mode 100644 index 00000000..6f358d10 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/SelfieDestination.kt @@ -0,0 +1,14 @@ +package io.falu.identity.navigation + +internal class SelfieDestination : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + internal companion object { + const val SELFIE = "selfie" + + val ROUTE = object : WorkflowRoute() { + override val base: String = SELFIE + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/SupportDestination.kt b/identity/src/main/java/io/falu/identity/navigation/SupportDestination.kt new file mode 100644 index 00000000..2fadea9b --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/SupportDestination.kt @@ -0,0 +1,14 @@ +package io.falu.identity.navigation + +internal class SupportDestination : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + internal companion object { + const val SUPPORT = "support" + + val ROUTE = object : WorkflowRoute() { + override val base: String = SUPPORT + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/TaxPinDestination.kt b/identity/src/main/java/io/falu/identity/navigation/TaxPinDestination.kt new file mode 100644 index 00000000..bce4a0cf --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/TaxPinDestination.kt @@ -0,0 +1,14 @@ +package io.falu.identity.navigation + +internal class TaxPinDestination : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + internal companion object { + const val TAX_PIN = "tax_pin" + + val ROUTE = object : WorkflowRoute() { + override val base: String = TAX_PIN + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/UploadCaptureDestination.kt b/identity/src/main/java/io/falu/identity/navigation/UploadCaptureDestination.kt new file mode 100644 index 00000000..7a84966a --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/UploadCaptureDestination.kt @@ -0,0 +1,32 @@ +package io.falu.identity.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import io.falu.identity.api.models.IdentityDocumentType + +internal data class UploadCaptureDestination(val documentType: IdentityDocumentType) : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + override val routeWithArgs: String = workflowRoute.withParameters( + KEY_DOCUMENT_TYPE to documentType + ) + + internal companion object { + const val UPLOAD_CAPTURE = "document_capture_method_upload" + internal const val KEY_DOCUMENT_TYPE = "document_type" + + internal fun identityDocumentType(entry: NavBackStackEntry) = + entry.getSerializable(KEY_DOCUMENT_TYPE)!! + + val ROUTE = object : WorkflowRoute() { + override val base: String = UPLOAD_CAPTURE + override val arguments = listOf( + navArgument(KEY_DOCUMENT_TYPE) { + type = NavType.EnumType(IdentityDocumentType::class.java) + } + ) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/navigation/WelcomeDestination.kt b/identity/src/main/java/io/falu/identity/navigation/WelcomeDestination.kt new file mode 100644 index 00000000..cfa269e9 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/navigation/WelcomeDestination.kt @@ -0,0 +1,14 @@ +package io.falu.identity.navigation + +internal class WelcomeDestination : IdentityDestination() { + override val workflowRoute: WorkflowRoute + get() = ROUTE + + internal companion object { + const val WELCOME = "welcome" + + val ROUTE = object : WorkflowRoute() { + override val base: String = WELCOME + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/scan/AbstractScanner.kt b/identity/src/main/java/io/falu/identity/scan/AbstractScanner.kt index c49c4908..a28af872 100644 --- a/identity/src/main/java/io/falu/identity/scan/AbstractScanner.kt +++ b/identity/src/main/java/io/falu/identity/scan/AbstractScanner.kt @@ -1,34 +1,40 @@ package io.falu.identity.scan -import android.renderscript.RenderScript import io.falu.identity.ai.DetectionOutput +import io.falu.identity.analytics.ModelPerformanceMonitor import io.falu.identity.api.models.verification.VerificationCapture import io.falu.identity.camera.CameraView +import java.io.File -internal abstract class AbstractScanner( - private val callbacks: ScanResultCallback -) { +internal abstract class AbstractScanner { + internal lateinit var callbacks: ScanResultCallback + + internal var disposition: ScanDisposition? = null + private var cameraView: CameraView? = null - protected var disposition: ScanDisposition? = null private var previousDisposition: ScanDisposition? = null private var isFirstOutput = false - internal abstract fun scan( - view: CameraView, - scanType: ScanDisposition.DocumentScanType, + internal fun onUpdateCameraView(view: CameraView) { + if (cameraView == null) { + cameraView = view + onCameraViewReady() + } + } + + protected open fun onCameraViewReady() {} + + internal abstract fun addAnalyzers( + model: File, capture: VerificationCapture, - renderScript: RenderScript + scanType: ScanDisposition.DocumentScanType, + performanceMonitor: ModelPerformanceMonitor ) - internal fun stopScan(view: CameraView) { - view.analyzers.clear() - view.stopAnalyzer() - } + fun requireCameraView() = requireNotNull(cameraView) internal fun onResult(output: DetectionOutput) { - requireNotNull(disposition) { "Initial Disposition cannot be null" } - val (provisionalResult, identityResult) = collectResults(output) callbacks.onProgress(provisionalResult) @@ -59,11 +65,11 @@ internal abstract class AbstractScanner( val provisionalResult = ProvisionalResult(disposition!!) provisionalResult to - if (disposition!!.terminate) { - IdentityResult(output, disposition!!) - } else { - null - } + if (disposition!!.terminate) { + IdentityResult(output, disposition!!) + } else { + null + } } else { isFirstOutput = true ProvisionalResult(disposition!!) to null diff --git a/identity/src/main/java/io/falu/identity/scan/DocumentScanner.kt b/identity/src/main/java/io/falu/identity/scan/DocumentScanner.kt new file mode 100644 index 00000000..f290f2c8 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/scan/DocumentScanner.kt @@ -0,0 +1,43 @@ +package io.falu.identity.scan + +import android.content.Context +import io.falu.identity.R +import io.falu.identity.ai.DocumentDetectionAnalyzer +import io.falu.identity.ai.DocumentDispositionMachine +import io.falu.identity.analytics.ModelPerformanceMonitor +import io.falu.identity.api.models.verification.VerificationCapture +import io.falu.identity.utils.getRenderScript +import io.falu.identity.utils.toFraction +import org.joda.time.DateTime +import java.io.File + +internal class DocumentScanner(private val context: Context) : AbstractScanner() { + + override fun onCameraViewReady() { + requireCameraView().ivCameraBorder.setBackgroundResource(R.drawable.ic_falu_document_border) + } + + override fun addAnalyzers( + model: File, + capture: VerificationCapture, + scanType: ScanDisposition.DocumentScanType, + performanceMonitor: ModelPerformanceMonitor + ) { + val machine = DocumentDispositionMachine( + timeout = DateTime.now().plusMillis(capture.timeout), + iou = capture.blur?.iou?.toFraction() ?: 0.95f, + requiredTime = capture.blur?.duration?.div(1000) ?: 5 + ) + + disposition = ScanDisposition.Start(scanType, machine) + + requireCameraView().analyzers.add( + DocumentDetectionAnalyzer.Builder( + model = model, + capture.models.document.threshold, + context.getRenderScript(), + performanceMonitor + ).instance { onResult(it) } + ) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/scan/FaceScanner.kt b/identity/src/main/java/io/falu/identity/scan/FaceScanner.kt new file mode 100644 index 00000000..3acb6bc2 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/scan/FaceScanner.kt @@ -0,0 +1,31 @@ +package io.falu.identity.scan + +import android.content.Context +import io.falu.identity.ai.FaceDetectionAnalyzer +import io.falu.identity.analytics.ModelPerformanceMonitor +import io.falu.identity.api.models.verification.VerificationCapture +import io.falu.identity.utils.getRenderScript +import java.io.File + +internal class FaceScanner(private val context: Context) : AbstractScanner() { + + override fun addAnalyzers( + model: File, + capture: VerificationCapture, + scanType: ScanDisposition.DocumentScanType, + performanceMonitor: ModelPerformanceMonitor + ) { + requireCameraView().analyzers.add( + FaceDetectionAnalyzer.Builder( + model = model, + performanceMonitor, + capture.models.face?.threshold ?: 0.75f, + context.getRenderScript() + ).instance { onResult(it) } + ) + } + + override fun onCameraViewReady() { + // requireCameraView().ivCameraBorder.setBackgroundResource(R.drawable.ic_falu_selfie_border) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/scan/result.kt b/identity/src/main/java/io/falu/identity/scan/IdentityResult.kt similarity index 100% rename from identity/src/main/java/io/falu/identity/scan/result.kt rename to identity/src/main/java/io/falu/identity/scan/IdentityResult.kt diff --git a/identity/src/main/java/io/falu/identity/scan/ScanDisposition.kt b/identity/src/main/java/io/falu/identity/scan/ScanDisposition.kt index 5027e24e..27cabd9d 100644 --- a/identity/src/main/java/io/falu/identity/scan/ScanDisposition.kt +++ b/identity/src/main/java/io/falu/identity/scan/ScanDisposition.kt @@ -1,6 +1,10 @@ package io.falu.identity.scan import io.falu.identity.ai.DetectionOutput +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.navigation.IdentityDestination +import io.falu.identity.navigation.ScanCaptureDestination +import io.falu.identity.navigation.SelfieDestination import org.joda.time.DateTime /** @@ -26,16 +30,36 @@ internal sealed class ScanDisposition( val isFront: Boolean get() { - return this == DL_FRONT || - this == ID_FRONT || - this == PASSPORT + return this == DL_FRONT || this == ID_FRONT || this == PASSPORT } val isBack: Boolean get() { - return this == DL_BACK || - this == ID_BACK + return this == DL_BACK || this == ID_BACK } + + fun toScanDestination(): IdentityDestination { + return when (this) { + DL_BACK, + DL_FRONT -> ScanCaptureDestination( + documentType = IdentityDocumentType.DRIVING_LICENSE, + popToCapture = true + ) + + ID_BACK, + ID_FRONT -> ScanCaptureDestination( + documentType = IdentityDocumentType.IDENTITY_CARD, + popToCapture = true + ) + + PASSPORT -> ScanCaptureDestination( + documentType = IdentityDocumentType.PASSPORT, + popToCapture = true + ) + + SELFIE -> SelfieDestination() + } + } } /** @@ -55,8 +79,7 @@ internal sealed class ScanDisposition( type: DocumentScanType, detector: ScanDispositionDetector, internal var reached: DateTime = DateTime.now() - ) : - ScanDisposition(type, detector, false) { + ) : ScanDisposition(type, detector, false) { override fun next(output: DetectionOutput) = dispositionDetector.fromDetected(this, output) } @@ -67,8 +90,7 @@ internal sealed class ScanDisposition( type: DocumentScanType, detector: ScanDispositionDetector, val reached: DateTime = DateTime.now() - ) : - ScanDisposition(type, detector, false) { + ) : ScanDisposition(type, detector, false) { override fun next(output: DetectionOutput): ScanDisposition = dispositionDetector.fromDesired(this, output) } @@ -80,8 +102,7 @@ internal sealed class ScanDisposition( type: DocumentScanType, detector: ScanDispositionDetector, val reached: DateTime = DateTime.now() - ) : - ScanDisposition(type, detector, false) { + ) : ScanDisposition(type, detector, false) { override fun next(output: DetectionOutput): ScanDisposition = dispositionDetector.fromUndesired(this, output) } diff --git a/identity/src/main/java/io/falu/identity/screens/CameraPermissionLaunchedEffect.kt b/identity/src/main/java/io/falu/identity/screens/CameraPermissionLaunchedEffect.kt new file mode 100644 index 00000000..65eb684e --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/CameraPermissionLaunchedEffect.kt @@ -0,0 +1,47 @@ +package io.falu.identity.screens + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.core.content.ContextCompat + +@Composable +internal fun CameraPermissionLaunchEffect(onPermissionGranted: () -> Unit, onPermissionDenied: () -> Unit) { + val context = LocalContext.current + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + hasCameraPermission = isGranted + if (isGranted) { + onPermissionGranted() + } else { + onPermissionDenied() + } + } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) { + permissionLauncher.launch(Manifest.permission.CAMERA) + } else { + onPermissionGranted() + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/ConfirmationScreen.kt b/identity/src/main/java/io/falu/identity/screens/ConfirmationScreen.kt new file mode 100644 index 00000000..9952a16d --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/ConfirmationScreen.kt @@ -0,0 +1,72 @@ +package io.falu.identity.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import io.falu.identity.IdentityVerificationResult +import io.falu.identity.IdentityVerificationResultCallback +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_CONFIRMATION +import io.falu.identity.ui.ObserveVerificationAndCompose +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun ConfirmationScreen( + viewModel: IdentityVerificationViewModel, + callback: IdentityVerificationResultCallback +) { + val verificationResponse by viewModel.verification.observeAsState() + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_CONFIRMATION) + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = dimensionResource(R.dimen.content_padding_normal)) + ) { + Text( + text = stringResource(R.string.confirmation_title_verification_processing), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.content_padding_normal)), + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(R.string.confirmation_text_gratitude), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = dimensionResource(R.dimen.element_spacing_normal)), + textAlign = TextAlign.Center + ) + + Button( + onClick = { + viewModel.reportSuccessfulVerificationTelemetry() + callback.onFinishWithResult(IdentityVerificationResult.Succeeded) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.button_finish)) + } + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/DocumentCaptureMethodsScreen.kt b/identity/src/main/java/io/falu/identity/screens/DocumentCaptureMethodsScreen.kt new file mode 100644 index 00000000..694dbf02 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/DocumentCaptureMethodsScreen.kt @@ -0,0 +1,134 @@ +package io.falu.identity.screens + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_DOCUMENT_CAPTURE_METHODS +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.UploadMethod +import io.falu.identity.ui.ObserveVerificationAndCompose +import io.falu.identity.ui.theme.IdentityTheme +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun DocumentCaptureMethodsScreen( + viewModel: IdentityVerificationViewModel, + documentType: IdentityDocumentType, + navigateToCaptureMethod: (UploadMethod) -> Unit +) { + val verificationResponse by viewModel.verification.observeAsState() + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_DOCUMENT_CAPTURE_METHODS) + ) + } + + DocumentSelectionView( + documentType, + allowUploads = true, + onCaptureMethod = { navigateToCaptureMethod(it) } + ) + } +} + +@Composable +private fun DocumentSelectionView( + documentType: IdentityDocumentType, + allowUploads: Boolean = true, + onCaptureMethod: (UploadMethod) -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.content_padding_normal)) + ) { + Text( + text = stringResource( + R.string.document_capture_method_subtitle, + stringResource(documentType.titleRes) + ), + style = MaterialTheme.typography.bodyMedium + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.content_padding_normal)) + ) { + CaptureMethodCard( + methodText = stringResource(R.string.document_capture_method_scan), + modifier = Modifier.padding(bottom = dimensionResource(R.dimen.element_spacing_normal)), + onCardClick = { onCaptureMethod(UploadMethod.AUTO) } + ) + + CaptureMethodCard( + methodText = stringResource(R.string.document_capture_method_photo), + modifier = Modifier.padding(bottom = dimensionResource(R.dimen.element_spacing_normal)), + onCardClick = { onCaptureMethod(UploadMethod.MANUAL) } + ) + + if (allowUploads) { + CaptureMethodCard( + methodText = stringResource(R.string.document_capture_method_upload), + modifier = Modifier.padding(bottom = dimensionResource(R.dimen.element_spacing_normal)), + onCardClick = { onCaptureMethod(UploadMethod.UPLOAD) } + ) + } + } + } +} + +@Composable +internal fun CaptureMethodCard(methodText: String, modifier: Modifier = Modifier, onCardClick: () -> Unit = {}) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onCardClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + shape = MaterialTheme.shapes.medium + ) { + // Card content + Row( + modifier = Modifier + .wrapContentWidth() + .padding(all = dimensionResource(id = R.dimen.content_padding_normal)), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = methodText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Preview +@Composable +fun DocumentCaptureScreenPreview() { + IdentityTheme { + DocumentSelectionView(IdentityDocumentType.PASSPORT, false) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/DocumentSelectionScreen.kt b/identity/src/main/java/io/falu/identity/screens/DocumentSelectionScreen.kt new file mode 100644 index 00000000..ff806325 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/DocumentSelectionScreen.kt @@ -0,0 +1,202 @@ +package io.falu.identity.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.ElevatedFilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_DOCUMENT_SELECTION +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.country.Country +import io.falu.identity.api.models.country.SupportedCountry +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.ui.CountriesView +import io.falu.identity.ui.LoadingButton +import io.falu.identity.ui.ObserveVerificationAndCompose +import io.falu.identity.ui.theme.IdentityTheme +import io.falu.identity.viewModel.IdentityVerificationViewModel +import software.tingle.api.ResourceResponse + +internal const val TAG_DOCUMENT_ID_CARD = "Identity Card" +internal const val TAG_DOCUMENT_PASSPORT = "Passport" +internal const val TAG_DOCUMENT_DL = "Driving Licence" +internal const val TAG_CONTINUE_BUTTON = "Continue" + +@Composable +internal fun DocumentSelectionScreen( + viewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions +) { + val verificationResponse by viewModel.verification.observeAsState() + val supportedCountriesResponse by viewModel.supportedCountries.observeAsState() + var selectedDocumentType by remember { mutableStateOf(null) } + var selectedCountry by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { verification -> + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_DOCUMENT_SELECTION) + ) + } + + Column { + DocumentSelectionView( + response = supportedCountriesResponse, + verification.options.document.allowed, + onSelected = { documentType, country -> + selectedDocumentType = documentType + selectedCountry = country + } + ) + + Box(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.content_padding_normal))) { + LoadingButton( + text = stringResource(R.string.button_continue), + enabled = selectedDocumentType != null, + modifier = Modifier.semantics { testTag = TAG_CONTINUE_BUTTON } + ) { + isLoading = true + + val updateOptions = VerificationUpdateOptions(country = selectedCountry?.country?.code.orEmpty()) + + viewModel.updateVerification( + updateOptions, + onSuccess = { + if (verification.idNumberVerification) { + navActions.navigateToDocumentVerification(selectedDocumentType!!) + } else { + navActions.navigateToDocumentCaptureMethods(selectedDocumentType!!) + } + }, + onError = { throwable -> + navActions.navigateToErrorWithApiExceptions(throwable) + }, + onFailure = { throwable -> + navActions.navigateToErrorWithFailure(throwable) + } + ) + + isLoading = false + } + } + } + } +} + +@Composable +private fun DocumentSelectionView( + response: ResourceResponse>?, + allowedDocuments: List, + onSelected: (IdentityDocumentType, SupportedCountry) -> Unit +) { + var selectedCountry by remember { mutableStateOf(null) } + + Column { + CountriesView(response, selectedCountry, onCountrySelected = { selectedCountry = it }) + DocumentOptions(allowedDocuments, selectedCountry, onSelected) + } +} + +@Composable +private fun DocumentOptions( + allowedDocuments: List, + supportedCountry: SupportedCountry?, + onSelected: (IdentityDocumentType, SupportedCountry) -> Unit +) { + val context = LocalContext.current + var selected by remember { mutableStateOf(null) } + + fun isSelected(documentType: IdentityDocumentType) = selected == documentType + + fun isEnabled(documentType: IdentityDocumentType) = supportedCountry?.documents?.contains(documentType) ?: false + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.content_padding_normal)) + ) { + Text( + text = stringResource(id = R.string.document_selection_accepted_documents), + modifier = Modifier + .padding(top = dimensionResource(R.dimen.content_padding_normal)) + .padding(bottom = dimensionResource(R.dimen.element_spacing_normal)) + ) + + allowedDocuments.reversed().forEach { documentType -> + ElevatedFilterChip( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensionResource(R.dimen.element_spacing_normal)) + .semantics { testTag = context.getString(documentType.titleRes) }, + selected = isSelected(documentType), + enabled = isEnabled(documentType), + onClick = { + selected = documentType + onSelected(documentType, supportedCountry!!) + }, + label = { + Text( + text = stringResource(documentType.titleRes), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(vertical = dimensionResource(R.dimen.content_padding_normal)) + ) + }, + trailingIcon = { + if (isSelected(documentType)) { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + } + } + ) + } + } +} + +@Preview +@Composable +fun DocumentSelectionScreenPreview() { + val country = SupportedCountry( + Country("us", "US", "https://cdn.tinglesoftware.com/statics/countries/flags/svg/ken.svg"), + documents = mutableListOf(IdentityDocumentType.PASSPORT) + ) + + IdentityTheme { + DocumentOptions( + listOf( + IdentityDocumentType.PASSPORT, + IdentityDocumentType.IDENTITY_CARD, + IdentityDocumentType.DRIVING_LICENSE + ), + country + ) { _, _ -> } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/DocumentVerificationScreen.kt b/identity/src/main/java/io/falu/identity/screens/DocumentVerificationScreen.kt new file mode 100644 index 00000000..619a7df6 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/DocumentVerificationScreen.kt @@ -0,0 +1,404 @@ +package io.falu.identity.screens + +import android.net.Uri +import android.text.format.DateUtils +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_DOCUMENT_VERIFICATION +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.WorkspaceInfo +import io.falu.identity.api.models.verification.Gender +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.api.models.verification.VerificationIdNumberUpload +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.api.models.verification.VerificationUploadRequest +import io.falu.identity.navigation.DocumentVerificationDestination +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.ui.IdentityVerificationHeader +import io.falu.identity.ui.LoadingButton +import io.falu.identity.ui.TextFieldError +import io.falu.identity.ui.theme.IdentityTheme +import io.falu.identity.viewModel.IdentityVerificationViewModel +import java.util.Date + +@Composable +internal fun DocumentVerificationScreen( + viewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions +) { + val verificationResponse by viewModel.verification.observeAsState() + var loading by remember { mutableStateOf(false) } + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { verification -> + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_DOCUMENT_VERIFICATION) + ) + } + + DocumentVerificationForm(loading = loading) { idNumberUpload, isLoading -> + loading = isLoading + attemptSubmission(viewModel, navActions, idNumberUpload, verification, onLoading = { loading = it }) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DocumentVerificationForm(loading: Boolean, onSubmit: (VerificationIdNumberUpload, Boolean) -> Unit) { + val context = LocalContext.current + val scrollState = rememberScrollState() + + var documentNumber by remember { mutableStateOf("") } + var firstName by remember { mutableStateOf("") } + var lastName by remember { mutableStateOf("") } + var gender by remember { mutableStateOf("") } + var birthday by remember { mutableStateOf(null) } + val genderOptions = Gender.entries.map { context.getString(it.desc) } + var expandedGenderMenu by remember { mutableStateOf(false) } + var showDatePicker by remember { mutableStateOf(false) } + + var documentNumberError by remember { mutableStateOf(false) } + var firstNameError by remember { mutableStateOf(false) } + var lastNameError by remember { mutableStateOf(false) } + var genderError by remember { mutableStateOf(false) } + var birthdayError by remember { mutableStateOf(false) } + + val formValid: () -> Boolean = { + documentNumberError = documentNumber.isEmpty() + firstNameError = firstName.isEmpty() + lastNameError = lastName.isEmpty() + genderError = gender.isEmpty() + birthdayError = birthday == null + !(documentNumberError || firstNameError || lastNameError || genderError || birthdayError) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding( + horizontal = dimensionResource(R.dimen.content_padding_normal), + vertical = dimensionResource(R.dimen.element_spacing_normal) + ), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.element_spacing_normal_half)) + ) { + Text( + text = stringResource(R.string.document_verification_title_document_verification), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = dimensionResource(R.dimen.element_spacing_normal)) + ) + + OutlinedTextField( + value = documentNumber, + onValueChange = { documentNumber = it }, + maxLines = 1, + label = { + Text( + text = stringResource(R.string.document_verification_hint_document_number), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.element_spacing_normal_half)), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + ) + if (documentNumberError) { + TextFieldError(error = stringResource(R.string.document_verification_error_invalid_document_number)) + } + + OutlinedTextField( + value = firstName, + onValueChange = { firstName = it }, + maxLines = 1, + label = { + Text( + text = stringResource(R.string.document_verification_hint_first_name), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp + ) + }, + keyboardOptions = KeyboardOptions.Default, + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.element_spacing_normal_half)), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + ) + if (firstNameError) { + TextFieldError(error = stringResource(R.string.document_verification_error_invalid_first_name)) + } + + OutlinedTextField( + value = lastName, + onValueChange = { lastName = it }, + maxLines = 1, + label = { + Text( + text = stringResource(R.string.document_verification_hint_last_name), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp + ) + }, + keyboardOptions = KeyboardOptions.Default, + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.element_spacing_normal_half)), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + ) + if (lastNameError) { + TextFieldError(error = stringResource(R.string.document_verification_error_invalid_last_name)) + } + + ExposedDropdownMenuBox( + expanded = expandedGenderMenu, + onExpandedChange = { expandedGenderMenu = !expandedGenderMenu } + ) { + OutlinedTextField( + value = gender, + onValueChange = { gender = it }, + label = { + Text( + text = stringResource(R.string.document_verification_hint_gender), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp + ) + }, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .clickable { expandedGenderMenu = true }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expandedGenderMenu, + modifier = Modifier.menuAnchor(MenuAnchorType.SecondaryEditable) + ) + }, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + ) + ExposedDropdownMenu( + expanded = expandedGenderMenu, + onDismissRequest = { expandedGenderMenu = false } + ) { + genderOptions.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + gender = option + expandedGenderMenu = false + } + ) + } + } + } + if (genderError) { + TextFieldError(error = stringResource(R.string.document_verification_error_invalid_first_name)) + } + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.element_spacing_normal_half))) + + Box( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = Color.Gray, + shape = RoundedCornerShape(dimensionResource(R.dimen.element_spacing_normal)) + ) + .clickable { showDatePicker = true } + ) { + Row( + modifier = Modifier + .padding(all = dimensionResource(R.dimen.content_padding_normal)) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (birthday == null) { + stringResource(R.string.document_verification_hint_birthday) + } else { + DateUtils.formatDateTime( + context, + birthday?.time ?: 0, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR + ) + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = Color.Gray + ) + } + } + if (birthdayError) { + TextFieldError(error = stringResource(R.string.document_verification_hint_birthday)) + } + + if (showDatePicker) { + DateOfBirthPicker(onDismiss = { showDatePicker = false }) { dateInMillis -> + showDatePicker = false + dateInMillis?.let { birthday = Date(it) } + } + } + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.element_spacing_normal_half))) + + LoadingButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.button_continue), + isLoading = loading + ) { + if (formValid()) { + onSubmit(attemptSubmission(documentNumber, firstName, lastName, birthday!!, gender), true) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DateOfBirthPicker(onDismiss: () -> Unit, onSelected: (Long?) -> Unit) { + val dateState = rememberDatePickerState( + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long) = utcTimeMillis <= System.currentTimeMillis() + } + ) + val millisToLocalDate = dateState.selectedDateMillis + + DatePickerDialog( + onDismissRequest = { onDismiss() }, + confirmButton = { + Button( + onClick = { onSelected(millisToLocalDate) } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) { + DatePicker( + state = dateState, + showModeToggle = true + ) + } +} + +private fun attemptSubmission( + documentNumber: String, + firstName: String, + lastName: String, + dateOfBirth: Date, + gender: String +) = VerificationIdNumberUpload( + type = IdentityDocumentType.IDENTITY_CARD, + number = documentNumber, + firstName = firstName, + lastName = lastName, + birthday = dateOfBirth, + sex = gender +) + +private fun attemptSubmission( + viewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + idNumberUpload: VerificationIdNumberUpload, + verification: Verification, + onLoading: (Boolean) -> Unit = {} +) { + val options = VerificationUpdateOptions(idNumber = idNumberUpload) + val uploadRequest = VerificationUploadRequest(idNumber = idNumberUpload) + + viewModel.updateVerification( + options, + onSuccess = { + onLoading(false) + viewModel.attemptDocumentSubmission( + DocumentVerificationDestination.ROUTE.route, + navActions, + verification, + uploadRequest + ) + }, + onError = { throwable -> + onLoading(false) + navActions.navigateToErrorWithApiExceptions(throwable) + }, + onFailure = { throwable -> + onLoading(false) + navActions.navigateToErrorWithFailure(throwable) + } + ) +} + +@Preview +@Composable +private fun DocumentVerificationPreview() { + IdentityTheme { + IdentityVerificationHeader(Uri.EMPTY, WorkspaceInfo(name = "Showcases", country = "US"), false) { + DocumentVerificationForm(loading = true) { _, _ -> } + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/InitialLoadingScreen.kt b/identity/src/main/java/io/falu/identity/screens/InitialLoadingScreen.kt new file mode 100644 index 00000000..826bc98d --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/InitialLoadingScreen.kt @@ -0,0 +1,90 @@ +package io.falu.identity.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.falu.core.utils.toThrowable +import io.falu.identity.FallbackUrlCallback +import io.falu.identity.R +import io.falu.identity.api.models.requirements.RequirementType +import io.falu.identity.api.models.requirements.RequirementType.Companion.nextDestination +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.ui.theme.IdentityTheme +import io.falu.identity.viewModel.IdentityVerificationViewModel +import software.tingle.api.ResourceResponse + +/** + * Initial screen with a spinner, to decide which screen to navigate to based on pending [RequirementType]. + */ +@Composable +internal fun InitialLoadingScreen( + identityViewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + fallbackUrlCallback: FallbackUrlCallback +) { + val verificationResponse by identityViewModel.verification.observeAsState() + + ObserveVerificationAndCompose( + verificationResponse, + onError = { throwable -> navActions.navigateToErrorWithFailure(throwable) } + ) { verification -> + LaunchedEffect(Unit) { + if (!verification.supported) { + fallbackUrlCallback.launchFallbackUrl(verification.url.orEmpty()) + } else { + verification.requirements.pending.nextDestination(navActions, verification) + } + } + } +} + +@Composable +internal fun LoadingScreen() { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + modifier = Modifier.size(dimensionResource(R.dimen.content_padding_normal_2x)), + color = MaterialTheme.colorScheme.secondary, + strokeWidth = 4.dp, + trackColor = Color.LightGray + ) + } +} + +@Composable +internal fun ObserveVerificationAndCompose( + response: ResourceResponse?, + onError: (Throwable?) -> Unit, + onSuccess: @Composable (Verification) -> Unit +) { + when { + response == null -> LoadingScreen() + response.successful() && response.resource != null -> response.resource?.let { onSuccess(it) } + else -> onError(response.toThrowable()) + } +} + +@Preview +@Composable +internal fun LoadingScreenPreview() { + IdentityTheme { + LoadingScreen() + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/SupportScreen.kt b/identity/src/main/java/io/falu/identity/screens/SupportScreen.kt new file mode 100644 index 00000000..a6de05ba --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/SupportScreen.kt @@ -0,0 +1,141 @@ +package io.falu.identity.screens + +import android.content.Intent +import android.net.Uri +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_SUPPORT +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun SupportScreen(identityViewModel: IdentityVerificationViewModel) { + val context = LocalContext.current + val verificationResponse by identityViewModel.verification.observeAsState() + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { verification -> + LaunchedEffect(Unit) { + identityViewModel.reportTelemetry( + identityViewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_SUPPORT) + ) + } + val support = verification.support + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + Text( + text = stringResource(R.string.support_title_help_needed), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.content_padding_normal)), + textAlign = TextAlign.Center + ) + + SupportOption( + iconRes = R.drawable.ic_call, + label = stringResource(R.string.support_text_call), + onClick = { + val intent = Intent(Intent.ACTION_DIAL).apply { + data = Uri.parse("tel:${support?.phone}") + } + context.startActivity(intent) + } + ) + + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + thickness = dimensionResource(R.dimen.element_spacing_normal_half), + modifier = Modifier.padding(top = dimensionResource(R.dimen.element_spacing_normal_half)) + ) + + SupportOption( + iconRes = R.drawable.ic_falu_email, + label = stringResource(R.string.support_text_email), + onClick = { + val intent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(support?.email)) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + ) + + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + thickness = dimensionResource(R.dimen.element_spacing_normal_half), + modifier = Modifier.padding(top = dimensionResource(R.dimen.element_spacing_normal_half)) + ) + + Text( + text = support?.url ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.element_spacing_normal)), + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun SupportOption( + @DrawableRes iconRes: Int, + label: String, + onClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(dimensionResource(R.dimen.element_spacing_normal)) + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(dimensionResource(R.dimen.content_padding_normal)) + ) + + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(horizontal = dimensionResource(R.dimen.element_spacing_normal_half)) + ) + + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/TaxPinVerificationScreen.kt b/identity/src/main/java/io/falu/identity/screens/TaxPinVerificationScreen.kt new file mode 100644 index 00000000..9c5144c4 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/TaxPinVerificationScreen.kt @@ -0,0 +1,157 @@ +package io.falu.identity.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.sp +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_TAX_PIN_VERIFICATION +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.api.models.verification.VerificationTaxPinUpload +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.api.models.verification.VerificationUploadRequest +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.navigation.TaxPinDestination +import io.falu.identity.ui.LoadingButton +import io.falu.identity.ui.TextFieldError +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun TaxPinVerificationScreen( + viewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions +) { + val verificationResponse by viewModel.verification.observeAsState() + var loading by remember { mutableStateOf(false) } + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { verification -> + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_TAX_PIN_VERIFICATION) + ) + } + + TaxPinVerificationForm(loading) { pinOptions, isLoading -> + loading = isLoading + attemptSubmission(viewModel, navActions, pinOptions, verification, onLoading = { loading = it }) + } + } +} + +@Composable +private fun TaxPinVerificationForm(loading: Boolean, onSubmit: (VerificationTaxPinUpload, Boolean) -> Unit) { + val scrollState = rememberScrollState() + + var pinNumber by remember { mutableStateOf("") } + var pinNumberError by remember { mutableStateOf(false) } + + val formValid: () -> Boolean = { + pinNumberError = pinNumber.isEmpty() + !(pinNumberError) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding( + horizontal = dimensionResource(R.dimen.content_padding_normal), + vertical = dimensionResource(R.dimen.element_spacing_normal) + ), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.element_spacing_normal_half)) + ) { + Text( + text = stringResource(R.string.tax_pin_verification_title_tax_pin), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = dimensionResource(R.dimen.element_spacing_normal)) + ) + + OutlinedTextField( + value = pinNumber, + onValueChange = { pinNumber = it }, + maxLines = 1, + label = { + Text( + text = stringResource(R.string.tax_pin_verification_hint_tax_pin), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.element_spacing_normal_half)), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + ) + if (pinNumberError) { + TextFieldError(error = stringResource(R.string.document_verification_error_invalid_document_number)) + } + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.element_spacing_normal_half))) + + LoadingButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.button_continue), + isLoading = loading + ) { + if (formValid()) { + onSubmit(attemptSubmission(taxPin = pinNumber), true) + } + } + } +} + +private fun attemptSubmission(taxPin: String) = VerificationTaxPinUpload(value = taxPin) + +private fun attemptSubmission( + viewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + pinUploadOptions: VerificationTaxPinUpload, + verification: Verification, + onLoading: (Boolean) -> Unit = {} +) { + val options = VerificationUpdateOptions(taxPin = pinUploadOptions) + val uploadRequest = VerificationUploadRequest(taxPin = pinUploadOptions) + + viewModel.updateVerification( + options, + onSuccess = { + onLoading(false) + viewModel.attemptDocumentSubmission( + TaxPinDestination.ROUTE.route, + navActions, + verification, + uploadRequest + ) + }, + onError = { throwable -> + onLoading(false) + navActions.navigateToErrorWithApiExceptions(throwable) + }, + onFailure = { throwable -> + onLoading(false) + navActions.navigateToErrorWithFailure(throwable) + } + ) +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/WelcomeScreen.kt b/identity/src/main/java/io/falu/identity/screens/WelcomeScreen.kt new file mode 100644 index 00000000..f4d5dba9 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/WelcomeScreen.kt @@ -0,0 +1,131 @@ +package io.falu.identity.screens + +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import io.falu.identity.IdentityVerificationResult +import io.falu.identity.IdentityVerificationResultCallback +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_WELCOME +import io.falu.identity.api.models.WorkspaceInfo +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.ui.IdentityVerificationHeader +import io.falu.identity.ui.LoadingButton +import io.falu.identity.ui.ObserveVerificationAndCompose +import io.falu.identity.ui.theme.IdentityTheme +import io.falu.identity.viewModel.IdentityVerificationViewModel + +internal const val WELCOME_ACCEPT_BUTTON = "Accept" + +@Composable +internal fun WelcomeScreen( + viewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + verificationResultCallback: IdentityVerificationResultCallback +) { + val response by viewModel.verification.observeAsState() + var isAcceptLoading by remember { mutableStateOf(false) } + + ObserveVerificationAndCompose(response, onError = {}) { verification -> + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_WELCOME) + ) + } + ConsentView( + workspaceName = verification.workspace.name.replaceFirstChar { it.uppercase() }, + loading = isAcceptLoading, + onAccepted = { + isAcceptLoading = true + viewModel.updateVerification( + VerificationUpdateOptions(consent = true), + onSuccess = { + isAcceptLoading = false + navActions.navigateToDocumentSelection() + }, + onError = { throwable -> + isAcceptLoading = false + navActions.navigateToErrorWithApiExceptions(throwable) + }, + onFailure = { throwable -> + navActions.navigateToErrorWithFailure(throwable) + } + ) + }, + onDeclined = { verificationResultCallback.onFinishWithResult(IdentityVerificationResult.Canceled) } + ) + } +} + +@Composable +private fun ConsentView( + workspaceName: String, + loading: Boolean, + onAccepted: () -> Unit, + onDeclined: () -> Unit +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.content_padding_normal)) + .verticalScroll(scrollState) + ) { + Text( + text = stringResource(R.string.welcome_subtitle, workspaceName.replaceFirstChar { it.uppercase() }), + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(id = R.dimen.element_spacing_normal)), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = stringResource(id = R.string.welcome_body), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = dimensionResource(id = R.dimen.element_spacing_normal)), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + + LoadingButton( + modifier = Modifier.semantics { testTag = WELCOME_ACCEPT_BUTTON }, + text = stringResource(R.string.welcome_button_accept), + isLoading = loading + ) { + onAccepted() + } + + LoadingButton(text = stringResource(R.string.welcome_button_decline)) { onDeclined() } + } +} + +@Preview +@Composable +private fun WelcomePreview() { + IdentityTheme { + IdentityVerificationHeader(Uri.EMPTY, WorkspaceInfo(name = "Showcases", country = "US"), false) { + ConsentView(workspaceName = "Showcases", loading = false, onAccepted = {}, onDeclined = {}) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/capture/CapturePreview.kt b/identity/src/main/java/io/falu/identity/screens/capture/CapturePreview.kt new file mode 100644 index 00000000..afef1190 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/capture/CapturePreview.kt @@ -0,0 +1,68 @@ +package io.falu.identity.screens.capture + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +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.asImageBitmap +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.falu.identity.R +import io.falu.identity.ui.theme.IdentityTheme + +@Composable +internal fun CapturePreview(bitmap: Bitmap, onContinue: () -> Unit, onDiscard: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card( + modifier = Modifier + .wrapContentSize() + .align(Alignment.CenterHorizontally), + shape = RoundedCornerShape(dimensionResource(R.dimen.element_spacing_normal)) + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(240.dp) + ) + } + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.element_spacing_normal_half))) + + Button( + onClick = onContinue, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.content_padding_normal)) + ) { + Text(text = stringResource(id = R.string.button_continue)) + } + } +} + +@Preview +@Composable +fun CapturePreviewPreview() { + IdentityTheme { + CapturePreview(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), {}, {}) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/capture/CaptureView.kt b/identity/src/main/java/io/falu/identity/screens/capture/CaptureView.kt new file mode 100644 index 00000000..d15a2426 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/capture/CaptureView.kt @@ -0,0 +1,162 @@ +package io.falu.identity.screens.capture + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.falu.identity.R +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.ui.theme.IdentityTheme + +@Composable +internal fun DocumentCaptureView( + title: String, + documentType: IdentityDocumentType, + isFrontLoading: Boolean = false, + isBackLoading: Boolean = false, + isFrontUploaded: Boolean, + isBackUploaded: Boolean, + onFront: () -> Unit, + onBack: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.content_padding_normal)) + ) { + Text( + text = title, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.element_spacing_normal)), + textAlign = TextAlign.Center + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(id = R.dimen.content_padding_normal)) + ) { + DocumentCard( + title = stringResource( + R.string.upload_document_capture_document_front, + stringResource(documentType.titleRes) + ), + buttonText = stringResource(id = R.string.button_select_front), + onSelectClicked = { onFront() }, + isUploaded = isFrontUploaded, + loading = isFrontLoading + ) + + if (documentType != IdentityDocumentType.PASSPORT) { + DocumentCard( + title = stringResource( + R.string.upload_document_capture_document_back, + stringResource(documentType.titleRes) + ), + buttonText = stringResource(id = R.string.button_select_back), + onSelectClicked = { onBack() }, + isUploaded = isBackUploaded, + loading = isBackLoading + ) + } + } + } +} + +@Composable +private fun DocumentCard( + title: String, + buttonText: String, + onSelectClicked: () -> Unit, + loading: Boolean, + isUploaded: Boolean +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensionResource(id = R.dimen.content_padding_normal)), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + shape = RoundedCornerShape(dimensionResource(id = R.dimen.element_spacing_normal)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(id = R.dimen.element_spacing_normal)), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(start = dimensionResource(R.dimen.element_spacing_normal)) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + if (!isUploaded && !loading) { + TextButton(onClick = onSelectClicked) { + Text(text = buttonText) + } + } + + if (!isUploaded && loading) { + CircularProgressIndicator( + modifier = Modifier.size(dimensionResource(R.dimen.content_padding_normal)), + color = MaterialTheme.colorScheme.secondary, + strokeWidth = dimensionResource(R.dimen.element_spacing_normal_quarter), + trackColor = Color.LightGray + ) + } + + if (isUploaded) { + Icon( + painter = painterResource(id = R.drawable.ic_check_circle), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(dimensionResource(id = R.dimen.content_padding_normal)) + ) + } + } + } + } +} + +@Preview +@Composable +internal fun DocumentCapturePreview() { + IdentityTheme { + DocumentCaptureView( + title = stringResource( + id = R.string.upload_document_capture_title, + stringResource(IdentityDocumentType.IDENTITY_CARD.titleRes) + ), + documentType = IdentityDocumentType.IDENTITY_CARD, + isFrontUploaded = false, + isBackUploaded = false, + onFront = {}, + onBack = {} + ) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/capture/DocumentScanLaunchedEffect.kt b/identity/src/main/java/io/falu/identity/screens/capture/DocumentScanLaunchedEffect.kt new file mode 100644 index 00000000..a7ad0440 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/capture/DocumentScanLaunchedEffect.kt @@ -0,0 +1,69 @@ +package io.falu.identity.screens.capture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import io.falu.identity.ai.DocumentDetectionOutput +import io.falu.identity.analytics.AnalyticsDisposition +import io.falu.identity.api.models.verification.VerificationCapture +import io.falu.identity.scan.DocumentScanner +import io.falu.identity.scan.ScanDisposition +import io.falu.identity.viewModel.DocumentScanViewModel +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun DocumentScanLaunchedEffect( + identityViewModel: IdentityVerificationViewModel, + documentScanViewModel: DocumentScanViewModel, + verificationCapture: VerificationCapture, + scanner: DocumentScanner, + scanType: ScanDisposition.DocumentScanType, + onScanComplete: (DocumentDetectionOutput) -> Unit, + onTimeout: () -> Unit, + onScannerReady: () -> Unit = {} +) { + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(documentScanViewModel) { + identityViewModel.documentDetectorModelFile.observe(lifecycleOwner) { model -> + if (model != null) { + scanner.addAnalyzers(model, verificationCapture, scanType, identityViewModel.modelPerformanceMonitor) + documentScanViewModel.initializeScanner(scanner) + onScannerReady() + } + } + + documentScanViewModel.documentScanCompleteDisposition.observe(lifecycleOwner) { result -> + if (result.disposition is ScanDisposition.Timeout) { + identityViewModel.reportTelemetry( + identityViewModel + .analyticsRequestBuilder + .documentScanTimeOut(scanType = (result.disposition as ScanDisposition.Timeout).type) + ) + onTimeout() + } else if (result.disposition is ScanDisposition.Completed) { + val output = result.output as DocumentDetectionOutput + reportSuccessfulScanTelemetry( + identityViewModel, + result.disposition as ScanDisposition.Completed, + output + ) + onScanComplete(output) + } + } + } +} + +private fun reportSuccessfulScanTelemetry( + identityViewModel: IdentityVerificationViewModel, + scanDisposition: ScanDisposition, + output: DocumentDetectionOutput +) { + val telemetryDisposition = if (scanDisposition.type.isFront) { + AnalyticsDisposition(frontModelScore = output.score, scanType = scanDisposition.type) + } else { + AnalyticsDisposition(backModelScore = output.score, scanType = scanDisposition.type) + } + + identityViewModel.modifyAnalyticsDisposition(disposition = telemetryDisposition) +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/capture/ManualCaptureScreen.kt b/identity/src/main/java/io/falu/identity/screens/capture/ManualCaptureScreen.kt new file mode 100644 index 00000000..841e916d --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/capture/ManualCaptureScreen.kt @@ -0,0 +1,100 @@ +package io.falu.identity.screens.capture + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.res.dimensionResource +import androidx.compose.ui.res.stringResource +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_UPLOAD_CAPTURE +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.navigation.ManualCaptureDestination +import io.falu.identity.ui.LoadingButton +import io.falu.identity.ui.ObserveVerificationAndCompose +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun ManualCaptureScreen( + viewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + documentType: IdentityDocumentType +) { + val context = LocalContext.current + + val verificationResponse by viewModel.verification.observeAsState() + val documentDisposition by viewModel.documentUploadDisposition.observeAsState() + + var frontLoading by remember { mutableStateOf(documentDisposition?.isFrontUpload ?: false) } + var backLoading by remember { mutableStateOf(documentDisposition?.isBackUploaded ?: false) } + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { verification -> + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_UPLOAD_CAPTURE) + ) + } + + Column { + DocumentCaptureView( + title = stringResource( + id = R.string.upload_document_capture_title, + stringResource(documentType.titleRes) + ), + documentType = documentType, + isFrontUploaded = documentDisposition?.isFrontUpload ?: false, + isBackUploaded = documentDisposition?.isBackUploaded ?: false, + isFrontLoading = frontLoading, + isBackLoading = backLoading, + onFront = { + frontLoading = true + viewModel.imageHandler.captureImageFront(context) + }, + onBack = { + backLoading = true + viewModel.imageHandler.captureImageBack(context) + } + ) + + Column(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.content_padding_normal))) { + LoadingButton( + text = stringResource(R.string.button_continue), + enabled = documentDisposition?.isBothUploadLoad ?: false + ) { + if (documentDisposition == null) return@LoadingButton + + val uploadRequest = documentDisposition!!.generateVerificationUploadRequest(documentType) + + val options = VerificationUpdateOptions(document = uploadRequest.document) + + viewModel.updateVerification( + options, + onSuccess = { + viewModel.attemptDocumentSubmission( + fromRoute = ManualCaptureDestination.ROUTE.route, + navActions = navActions, + verification = verification, + verificationRequest = uploadRequest + ) + }, + onError = { throwable -> + navActions.navigateToErrorWithApiExceptions(throwable) + }, + onFailure = { throwable -> + navActions.navigateToErrorWithFailure(throwable) + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/capture/ScanCaptureScreen.kt b/identity/src/main/java/io/falu/identity/screens/capture/ScanCaptureScreen.kt new file mode 100644 index 00000000..b690f8dd --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/capture/ScanCaptureScreen.kt @@ -0,0 +1,360 @@ +package io.falu.identity.screens.capture + +import androidx.camera.core.CameraSelector +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner +import io.falu.identity.R +import io.falu.identity.ai.DocumentDetectionOutput +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_AUTO_CAPTURE +import io.falu.identity.api.models.DocumentSide +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.getIdentityDocumentName +import io.falu.identity.api.models.getScanType +import io.falu.identity.api.models.verification.VerificationCapture +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.camera.CameraView +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.navigation.ScanCaptureDestination +import io.falu.identity.scan.DocumentScanner +import io.falu.identity.scan.ScanDisposition +import io.falu.identity.screens.CameraPermissionLaunchEffect +import io.falu.identity.ui.LoadingButton +import io.falu.identity.ui.ObserveVerificationAndCompose +import io.falu.identity.viewModel.DocumentScanViewModel +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun ScanCaptureScreen( + viewModel: IdentityVerificationViewModel, + documentScanViewModel: DocumentScanViewModel, + navActions: IdentityVerificationNavActions, + documentType: IdentityDocumentType +) { + val verificationResponse by viewModel.verification.observeAsState() + val documentDisposition by viewModel.documentUploadDisposition.observeAsState() + + var uploadFront by remember { mutableStateOf(false) } + var uploadBack by remember { mutableStateOf(false) } + + var frontLoading by remember { mutableStateOf(documentDisposition?.isFrontUpload ?: false) } + var backLoading by remember { mutableStateOf(documentDisposition?.isBackUploaded ?: false) } + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { verification -> + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_AUTO_CAPTURE) + ) + } + + CameraPermissionLaunchEffect( + onPermissionDenied = { navActions.navigateToCameraPermissionDenied() }, + onPermissionGranted = {} + ) + + Box(modifier = Modifier.wrapContentHeight()) { + when { + uploadFront -> { + DocumentSideCapture( + identityViewModel = viewModel, + documentScanViewModel = documentScanViewModel, + documentType = documentType, + scanType = documentType.getScanType().first, + capture = verification.capture, + onUpload = { + frontLoading = true + uploadDocument( + viewModel, + navActions, + it, + DocumentSide.FRONT, + onLoad = { loading -> frontLoading = loading } + ) + uploadFront = false + }, + onScanTimeOut = { + navActions.navigateToErrorWithScreenTimeout(documentType.getScanType().first) + } + ) + } + + uploadBack -> { + documentType.getScanType().second?.let { + DocumentSideCapture( + viewModel, + documentScanViewModel, + documentType, + it, + verification.capture, + onUpload = { output -> + backLoading = true + uploadDocument( + viewModel, + navActions, + output, + DocumentSide.BACK, + onLoad = { loading -> backLoading = loading } + ) + uploadBack = false + }, + onScanTimeOut = { + navActions.navigateToErrorWithScreenTimeout(documentType.getScanType().second) + } + ) + } + } + + else -> { + Column { + DocumentCaptureView( + title = stringResource( + id = R.string.upload_document_capture_title, + stringResource(documentType.titleRes) + ), + documentType = documentType, + isFrontUploaded = documentDisposition?.isFrontUpload ?: false, + isBackUploaded = documentDisposition?.isBackUploaded ?: false, + isFrontLoading = frontLoading, + isBackLoading = backLoading, + onFront = { uploadFront = true }, + onBack = { uploadBack = true } + ) + + Column( + modifier = Modifier + .padding(horizontal = dimensionResource(R.dimen.content_padding_normal)) + ) { + LoadingButton( + text = stringResource(R.string.button_continue), + enabled = documentDisposition?.isBothUploadLoad ?: false + ) { + if (documentDisposition == null) return@LoadingButton + + val uploadRequest = + documentDisposition!!.generateVerificationUploadRequest(documentType) + + val options = VerificationUpdateOptions(document = uploadRequest.document) + + viewModel.updateVerification( + options, + onSuccess = { + viewModel.attemptDocumentSubmission( + fromRoute = ScanCaptureDestination.ROUTE.route, + verification = verification, + navActions = navActions, + verificationRequest = uploadRequest + ) + }, + onError = { throwable -> + navActions.navigateToErrorWithApiExceptions(throwable) + }, + onFailure = { throwable -> + navActions.navigateToErrorWithFailure(throwable) + } + ) + } + } + } + } + } + } + } +} + +@Composable +private fun DocumentSideCapture( + identityViewModel: IdentityVerificationViewModel, + documentScanViewModel: DocumentScanViewModel, + documentType: IdentityDocumentType, + scanType: ScanDisposition.DocumentScanType, + capture: VerificationCapture, + onUpload: (DocumentDetectionOutput) -> Unit, + onScanTimeOut: () -> Unit +) { + val context = LocalContext.current + val owner = LocalLifecycleOwner.current + + val documentScanDisposition by documentScanViewModel.documentScanDisposition.observeAsState() + var detectionOutput by remember { mutableStateOf(null) } + + val scanner = remember { + DocumentScanner(context) + } + + LaunchedEffect(Unit) { + detectionOutput = null + documentScanViewModel.resetScanDispositions() + } + + val newDisplayState by remember { + derivedStateOf { + documentScanDisposition?.disposition + } + } + + LaunchedEffect(newDisplayState) { + if (newDisplayState is ScanDisposition.Completed) { + documentScanViewModel.stopScan(owner) + } + } + + val message = when (newDisplayState) { + is ScanDisposition.Start -> { + stringResource( + R.string.scan_capture_text_scan_message, + documentType.getIdentityDocumentName(context) + ) + } + + is ScanDisposition.Detected -> { + stringResource(R.string.scan_capture_text_document_detected) + } + + is ScanDisposition.Desired -> { + stringResource(R.string.scan_capture_text_document_scan_completed) + } + + is ScanDisposition.Undesired -> { + "" + } + + is ScanDisposition.Completed -> { + "" + } + + is ScanDisposition.Timeout, null -> { + "" + } + } + + DocumentScanLaunchedEffect( + identityViewModel = identityViewModel, + verificationCapture = capture, + documentScanViewModel = documentScanViewModel, + onScanComplete = { detectionOutput = it }, + scanType = scanType, + scanner = scanner, + onTimeout = { onScanTimeOut() } + ) { + documentScanViewModel.startScan(owner, capture, scanType) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.content_padding_normal)) + .padding(bottom = dimensionResource(R.dimen.element_spacing_normal)) + ) { + Text( + text = stringResource( + if (scanType.isFront) { + R.string.scan_capture_text_document_side_front + } else { + R.string.scan_capture_text_document_side_back + }, + stringResource(documentType.titleRes) + ), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.element_spacing_normal)), + textAlign = TextAlign.Center + ) + + Box { + when { + detectionOutput != null -> { + CapturePreview( + bitmap = detectionOutput!!.bitmap, + onContinue = { + onUpload(detectionOutput!!) + }, + onDiscard = { + detectionOutput = null + documentScanViewModel.startScan(owner, capture, scanType) + } + ) + } + + else -> { + Column { + Text( + text = message.ifEmpty { + stringResource( + R.string.scan_capture_text_scan_message, + documentType.getIdentityDocumentName(context) + ) + }, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(all = dimensionResource(R.dimen.element_spacing_normal)), + textAlign = TextAlign.Center + ) + + AndroidView( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + factory = { ctx -> + CameraView(ctx).apply { + bindLifecycle(owner) + lensFacing = CameraSelector.LENS_FACING_BACK + cameraViewType = if (documentType != IdentityDocumentType.PASSPORT) { + CameraView.CameraViewType.ID + } else { + CameraView.CameraViewType.PASSPORT + } + } + }, + update = { + scanner.onUpdateCameraView(it) + } + ) + } + } + } + } + } +} + +private fun uploadDocument( + identityViewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + output: DocumentDetectionOutput, + documentSide: DocumentSide, + onLoad: (Boolean) -> Unit +) { + identityViewModel.uploadScannedDocument( + output.bitmap, + documentSide, + output.score, + onError = { throwable -> + onLoad(false) + navActions.navigateToErrorWithApiExceptions(throwable) + }, + onFailure = { throwable -> + onLoad(false) + navActions.navigateToErrorWithFailure(throwable) + } + ) +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/capture/UploadCaptureScreen.kt b/identity/src/main/java/io/falu/identity/screens/capture/UploadCaptureScreen.kt new file mode 100644 index 00000000..ff58d298 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/capture/UploadCaptureScreen.kt @@ -0,0 +1,95 @@ +package io.falu.identity.screens.capture + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.res.dimensionResource +import androidx.compose.ui.res.stringResource +import io.falu.identity.R +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_UPLOAD_CAPTURE +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.navigation.UploadCaptureDestination +import io.falu.identity.ui.LoadingButton +import io.falu.identity.ui.ObserveVerificationAndCompose +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun UploadCaptureScreen( + viewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + documentType: IdentityDocumentType +) { + val context = LocalContext.current + val verificationResponse by viewModel.verification.observeAsState() + val documentDisposition by viewModel.documentUploadDisposition.observeAsState() + + var frontLoading by remember { mutableStateOf(documentDisposition?.isFrontUpload ?: false) } + var backLoading by remember { mutableStateOf(documentDisposition?.isBackUploaded ?: false) } + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { verification -> + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_UPLOAD_CAPTURE) + ) + } + + Column { + DocumentCaptureView( + title = stringResource( + id = R.string.upload_document_capture_title, + stringResource(documentType.titleRes) + ), + documentType = documentType, + isFrontUploaded = documentDisposition?.isFrontUpload ?: false, + isBackUploaded = documentDisposition?.isBackUploaded ?: false, + isFrontLoading = frontLoading, + isBackLoading = backLoading, + onFront = { + frontLoading = true + viewModel.imageHandler.pickImageFront() + }, + onBack = { + backLoading = true + viewModel.imageHandler.pickImageBack() + } + ) + + Column(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.content_padding_normal))) { + LoadingButton( + text = stringResource(R.string.button_continue), + enabled = documentDisposition?.isBothUploadLoad ?: false + ) { + if (documentDisposition == null) return@LoadingButton + + val uploadRequest = documentDisposition!!.generateVerificationUploadRequest(documentType) + + val options = VerificationUpdateOptions(document = uploadRequest.document) + + viewModel.updateVerification( + options, + onSuccess = { + viewModel.attemptDocumentSubmission( + fromRoute = UploadCaptureDestination.ROUTE.route, + verification = verification, + navActions = navActions, + verificationRequest = uploadRequest + ) + }, + onError = { throwable -> navActions.navigateToErrorWithApiExceptions(throwable) }, + onFailure = { throwable -> navActions.navigateToErrorWithFailure(throwable) } + ) + } + } + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/error/ErrorScreen.kt b/identity/src/main/java/io/falu/identity/screens/error/ErrorScreen.kt new file mode 100644 index 00000000..e6836e73 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/error/ErrorScreen.kt @@ -0,0 +1,109 @@ +package io.falu.identity.screens.error + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +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 io.falu.identity.R +import io.falu.identity.navigation.ErrorDestination +import io.falu.identity.ui.theme.IdentityTheme + +@Composable +internal fun ErrorScreen( + modifier: Modifier = Modifier, + title: String, + desc: String, + message: String? = null, + primaryButton: ErrorScreenButton? = null, + secondaryButton: ErrorScreenButton? = null +) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = dimensionResource(id = R.dimen.content_padding_normal)) + ) { + Column( + modifier = Modifier + .padding(top = dimensionResource(id = R.dimen.content_padding_normal)) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.element_spacing_normal))) + + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.element_spacing_normal))) + + Text( + text = message ?: "", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + Column(modifier = Modifier.padding(top = dimensionResource(id = R.dimen.element_spacing_normal))) { + primaryButton?.let { + Button(onClick = { primaryButton.onClick() }, modifier = modifier.fillMaxWidth()) { + Text(primaryButton.text) + } + } + + secondaryButton?.let { + Button(onClick = { secondaryButton.onClick() }, modifier = modifier.fillMaxWidth()) { + Text(secondaryButton.text) + } + } + } + } + } +} + +internal data class ErrorScreenButton( + val text: String, + val onClick: () -> Unit +) + +@Preview +@Composable +fun ErrorScreenPreview() { + val error = ErrorDestination( + title = stringResource(R.string.error_title), + desc = stringResource(R.string.error_title_unexpected_error), + backButtonText = stringResource(R.string.button_rectify) + ) + + IdentityTheme { + ErrorScreen( + title = error.title, + desc = error.desc, + primaryButton = ErrorScreenButton(text = stringResource(R.string.button_rectify), onClick = {}) + ) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/selfie/SelfieCameraLaunchEffect.kt b/identity/src/main/java/io/falu/identity/screens/selfie/SelfieCameraLaunchEffect.kt new file mode 100644 index 00000000..f500b957 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/selfie/SelfieCameraLaunchEffect.kt @@ -0,0 +1,53 @@ +package io.falu.identity.screens.selfie + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import io.falu.identity.ai.FaceDetectionOutput +import io.falu.identity.analytics.AnalyticsDisposition +import io.falu.identity.api.models.verification.VerificationCapture +import io.falu.identity.scan.FaceScanner +import io.falu.identity.scan.ScanDisposition +import io.falu.identity.viewModel.FaceScanViewModel +import io.falu.identity.viewModel.IdentityVerificationViewModel + +@Composable +internal fun SelfieCameraLaunchEffect( + identityViewModel: IdentityVerificationViewModel, + faceScanViewModel: FaceScanViewModel, + scanner: FaceScanner, + capture: VerificationCapture, + onScanComplete: (FaceDetectionOutput) -> Unit, + onTimeout: () -> Unit, + onScannerReady: () -> Unit = {} +) { + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(faceScanViewModel) { + identityViewModel.faceDetectorModelFile.observe(lifecycleOwner) { model -> + if (model != null) { + scanner.addAnalyzers( + model = model, + capture = capture, + ScanDisposition.DocumentScanType.SELFIE, + performanceMonitor = identityViewModel.modelPerformanceMonitor + ) + faceScanViewModel.initializeScanner(scanner) + onScannerReady() + } + } + + faceScanViewModel.faceScanCompleteDisposition.observe(lifecycleOwner) { result -> + if (result.disposition is ScanDisposition.Completed) { + val output = result.output as FaceDetectionOutput + identityViewModel.modifyAnalyticsDisposition( + disposition = AnalyticsDisposition(selfieModelScore = output.score) + ) + onScanComplete(output) + } else if (result.disposition is ScanDisposition.Timeout) { + identityViewModel.reportTelemetry(identityViewModel.analyticsRequestBuilder.selfieScanTimeOut()) + onTimeout() + } + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/screens/selfie/SelfieScreen.kt b/identity/src/main/java/io/falu/identity/screens/selfie/SelfieScreen.kt new file mode 100644 index 00000000..fdf558ce --- /dev/null +++ b/identity/src/main/java/io/falu/identity/screens/selfie/SelfieScreen.kt @@ -0,0 +1,307 @@ +package io.falu.identity.screens.selfie + +import android.content.Context +import androidx.camera.core.CameraSelector +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import io.falu.core.models.FaluFile +import io.falu.identity.R +import io.falu.identity.ai.FaceDetectionOutput +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_SELFIE +import io.falu.identity.api.models.UploadMethod +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.api.models.verification.VerificationCapture +import io.falu.identity.api.models.verification.VerificationSelfieUpload +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.api.models.verification.VerificationUploadRequest +import io.falu.identity.camera.CameraView +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.navigation.SelfieDestination +import io.falu.identity.scan.FaceScanner +import io.falu.identity.scan.ScanDisposition +import io.falu.identity.screens.CameraPermissionLaunchEffect +import io.falu.identity.screens.capture.CapturePreview +import io.falu.identity.ui.ObserveVerificationAndCompose +import io.falu.identity.viewModel.FaceScanViewModel +import io.falu.identity.viewModel.IdentityVerificationViewModel + +internal const val SELFIE_VIEW_ASPECT_RATIO = 1f + +@Composable +internal fun SelfieScreen( + viewModel: IdentityVerificationViewModel, + faceScanViewModel: FaceScanViewModel, + navActions: IdentityVerificationNavActions +) { + val context = LocalContext.current + val verificationResponse by viewModel.verification.observeAsState() + + ObserveVerificationAndCompose(verificationResponse, onError = {}) { verification -> + LaunchedEffect(Unit) { + viewModel.reportTelemetry( + viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_SELFIE) + ) + } + + CameraPermissionLaunchEffect( + onPermissionDenied = { navActions.navigateToCameraPermissionDenied() }, + onPermissionGranted = {} + ) + + SelfieCaptureView( + identityViewModel = viewModel, + faceScanViewModel = faceScanViewModel, + capture = verification.capture, + onUpload = { + uploadSelfie( + context = context, + identityViewModel = viewModel, + navActions = navActions, + verification = verification, + output = it + ) + }, + onScanTimeOut = { + navActions.navigateToErrorWithScreenTimeout(ScanDisposition.DocumentScanType.SELFIE) + } + ) + } +} + +@Composable +private fun SelfieCaptureView( + identityViewModel: IdentityVerificationViewModel, + faceScanViewModel: FaceScanViewModel, + capture: VerificationCapture, + onUpload: (FaceDetectionOutput) -> Unit, + onScanTimeOut: () -> Unit +) { + val owner = LocalLifecycleOwner.current + val context = LocalContext.current + val faceScanDisposition by faceScanViewModel.faceScanDisposition.observeAsState() + var detectionOutput by remember { mutableStateOf(null) } + + val scanner = remember { FaceScanner(context) } + + LaunchedEffect(Unit) { + detectionOutput = null + faceScanViewModel.resetScanDispositions() + } + + val newDisplayState by remember { + derivedStateOf { + faceScanDisposition?.disposition + } + } + + LaunchedEffect(newDisplayState) { + if (newDisplayState is ScanDisposition.Completed) { + faceScanViewModel.stopScan(owner) + } + } + + val message = when (newDisplayState) { + is ScanDisposition.Start -> { + stringResource(R.string.selfie_text_scan_message) + } + + is ScanDisposition.Detected -> { + stringResource(R.string.selfie_text_face_detected) + } + + is ScanDisposition.Desired -> { + stringResource(R.string.selfie_text_selfie_scan_completed) + } + + is ScanDisposition.Undesired -> { + "" + } + + is ScanDisposition.Completed -> { + "" + } + + is ScanDisposition.Timeout, null -> { + "" + } + } + + SelfieCameraLaunchEffect( + identityViewModel = identityViewModel, + faceScanViewModel = faceScanViewModel, + scanner = scanner, + capture = capture, + onScanComplete = { detectionOutput = it }, + onTimeout = { onScanTimeOut() } + ) { + faceScanViewModel.startScan(owner, capture) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.content_padding_normal)) + .padding(bottom = dimensionResource(R.dimen.element_spacing_normal)) + ) { + Text( + text = stringResource(R.string.selfie_text_take_selfie), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.element_spacing_normal)), + textAlign = TextAlign.Center + ) + + Box { + when { + detectionOutput != null -> { + CapturePreview( + bitmap = detectionOutput!!.bitmap, + onContinue = { + onUpload(detectionOutput!!) + }, + onDiscard = { + detectionOutput = null + faceScanViewModel.resetScanDispositions() + faceScanViewModel.startScan(owner, capture) + } + ) + } + + else -> { + Column { + Text( + text = message.ifEmpty { stringResource(R.string.selfie_text_scan_message) }, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(all = dimensionResource(R.dimen.element_spacing_normal)), + textAlign = TextAlign.Center + ) + + SelfieCameraView(owner, scanner) + } + } + } + } + } +} + +@Composable +private fun SelfieCameraView(owner: LifecycleOwner, scanner: FaceScanner) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .aspectRatio(SELFIE_VIEW_ASPECT_RATIO) + .padding(horizontal = dimensionResource(id = R.dimen.content_padding_normal)) + .clip(ShapeModifier) + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + CameraView(ctx).apply { + bindLifecycle(owner) + lensFacing = CameraSelector.LENS_FACING_FRONT + cameraViewType = CameraView.CameraViewType.FACE + } + }, + update = { + scanner.onUpdateCameraView(it) + } + ) + } +} + +private fun uploadSelfie( + context: Context, + identityViewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + verification: Verification, + output: FaceDetectionOutput +) { + identityViewModel.uploadSelfieImage( + output.bitmap, + onSuccess = { + submitSelfieAndUploadedDocuments( + context = context, + identityViewModel = identityViewModel, + navActions = navActions, + verification = verification, + file = it + ) + }, + onFailure = { throwable -> + navActions.navigateToErrorWithFailure(throwable) + }, + onError = { throwable -> + navActions.navigateToErrorWithApiExceptions(throwable) + } + ) +} + +private fun submitSelfieAndUploadedDocuments( + context: Context, + identityViewModel: IdentityVerificationViewModel, + navActions: IdentityVerificationNavActions, + verification: Verification, + file: FaluFile +) { + val selfie = VerificationSelfieUpload( + UploadMethod.AUTO, + file = file.id, + variance = 0F + ) + + val options = VerificationUpdateOptions(selfie = selfie) + val uploadRequest = VerificationUploadRequest(selfie = selfie) + + identityViewModel.updateVerification( + options, + onSuccess = { + identityViewModel.attemptDocumentSubmission( + fromRoute = SelfieDestination.ROUTE.route, + navActions = navActions, + verification = verification, + verificationRequest = uploadRequest + ) + }, + onFailure = { throwable -> + navActions.navigateToErrorWithFailure(throwable) + }, + onError = { throwable -> + navActions.navigateToErrorWithApiExceptions(throwable) + } + ) +} + +private val ShapeModifier = GenericShape { size, _ -> + addOval(Rect(Offset.Zero, size)) +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/selfie/FaceScanner.kt b/identity/src/main/java/io/falu/identity/selfie/FaceScanner.kt deleted file mode 100644 index b68479bf..00000000 --- a/identity/src/main/java/io/falu/identity/selfie/FaceScanner.kt +++ /dev/null @@ -1,42 +0,0 @@ -package io.falu.identity.selfie - -import android.renderscript.RenderScript -import io.falu.identity.ai.FaceDetectionAnalyzer -import io.falu.identity.analytics.ModelPerformanceMonitor -import io.falu.identity.api.models.verification.VerificationCapture -import io.falu.identity.camera.CameraView -import io.falu.identity.scan.AbstractScanner -import io.falu.identity.scan.IdentityResult -import io.falu.identity.scan.ProvisionalResult -import io.falu.identity.scan.ScanDisposition -import io.falu.identity.scan.ScanResultCallback -import org.joda.time.DateTime -import java.io.File - -internal class FaceScanner( - private val model: File, - private val threshold: Float, - private val performanceMonitor: ModelPerformanceMonitor, - callback: ScanResultCallback -) : AbstractScanner(callback) { - - override fun scan( - view: CameraView, - scanType: ScanDisposition.DocumentScanType, - capture: VerificationCapture, - renderScript: RenderScript - ) { - val machine = FaceDispositionMachine( - timeout = DateTime.now().plusMillis(capture.timeout) - ) - - disposition = - ScanDisposition.Start(scanType, machine) - - view.analyzers.add( - FaceDetectionAnalyzer - .Builder(model = model, performanceMonitor, threshold, renderScript) - .instance { onResult(it) } - ) - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/selfie/SelfieFragment.kt b/identity/src/main/java/io/falu/identity/selfie/SelfieFragment.kt deleted file mode 100644 index b799fc7a..00000000 --- a/identity/src/main/java/io/falu/identity/selfie/SelfieFragment.kt +++ /dev/null @@ -1,259 +0,0 @@ -package io.falu.identity.selfie - -import android.graphics.Bitmap -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.camera.core.CameraInfo -import androidx.camera.core.CameraSelector -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.core.exceptions.ApiException -import io.falu.core.models.FaluFile -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.ai.FaceDetectionOutput -import io.falu.identity.analytics.AnalyticsDisposition -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_SELFIE -import io.falu.identity.api.models.UploadMethod -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.api.models.verification.VerificationSelfieUpload -import io.falu.identity.api.models.verification.VerificationUpdateOptions -import io.falu.identity.api.models.verification.VerificationUploadRequest -import io.falu.identity.camera.CameraView -import io.falu.identity.databinding.FragmentSelfieBinding -import io.falu.identity.scan.ScanDisposition -import io.falu.identity.scan.ScanResult -import io.falu.identity.utils.getRenderScript -import io.falu.identity.utils.navigateToApiResponseProblemFragment -import io.falu.identity.utils.navigateToErrorFragment -import io.falu.identity.utils.submitVerificationData -import io.falu.identity.utils.updateVerification - -internal class SelfieFragment(identityViewModelFactory: ViewModelProvider.Factory) : Fragment() { - - private val identityViewModel: IdentityVerificationViewModel by activityViewModels { identityViewModelFactory } - private val faceScanViewModel: FaceScanViewModel by activityViewModels { faceScanViewModelFactory } - - private val faceScanViewModelFactory = - FaceScanViewModel.factoryProvider(this) { identityViewModel.modelPerformanceMonitor } - - private var _binding: FragmentSelfieBinding? = null - private val binding get() = _binding!! - - private lateinit var verificationRequest: VerificationUploadRequest - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSelfieBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - identityViewModel.reportTelemetry( - identityViewModel.analyticsRequestBuilder.screenPresented( - scanType = ScanDisposition.DocumentScanType.SELFIE, - screenName = SCREEN_NAME_SELFIE - ) - ) - - verificationRequest = - requireNotNull(VerificationUploadRequest.getFromBundle(requireArguments())) { - "Verification upload request is null" - } - - faceScanViewModel.resetScanDispositions() - resetUI() - - binding.viewCamera.lifecycleOwner = viewLifecycleOwner - binding.viewCamera.lensFacing = CameraSelector.LENS_FACING_FRONT - binding.viewCamera.cameraViewType = CameraView.CameraViewType.FACE - binding.buttonContinue.text = getString(R.string.button_continue) - - identityViewModel.observeForVerificationResults( - viewLifecycleOwner, - onError = {}, - onSuccess = { - binding.buttonReset.tag = it - initiateAnalyzer(it) - } - ) - - identityViewModel.observeForVerificationResults( - viewLifecycleOwner, - onSuccess = { onVerificationPage() }, - onError = {} - ) - - binding.buttonReset.setOnClickListener { - binding.viewSelfieCamera.visibility = View.VISIBLE - binding.viewSelfieResult.visibility = View.GONE - resetUI() - faceScanViewModel.resetScanDispositions() - val verification = binding.buttonReset.tag as Verification - scan(verification) - binding.viewCamera.startAnalyzer() - } - - faceScanViewModel.faceScanDisposition.observe(viewLifecycleOwner) { - updateUI(it) - } - } - - private fun initiateAnalyzer(verification: Verification) { - identityViewModel.faceDetectorModelFile.observe(viewLifecycleOwner) { - if (it != null) { - faceScanViewModel - .initialize(it, verification.capture.models.face?.threshold ?: THRESHOLD) - } - - scan(verification) - } - } - - private fun scan(verification: Verification) { - faceScanViewModel.scanner?.scan( - binding.viewCamera, - ScanDisposition.DocumentScanType.SELFIE, - verification.capture, - requireContext().getRenderScript() - ) - } - - private fun bindToUI(output: FaceDetectionOutput) { - binding.viewSelfieCamera.visibility = View.GONE - binding.viewSelfieResult.visibility = View.VISIBLE - binding.buttonContinue.visibility = View.VISIBLE - - binding.ivSelfie.setImageBitmap(output.bitmap) - - binding.buttonContinue.setOnClickListener { - binding.buttonContinue.showProgress() - uploadSelfie(output.bitmap) - } - } - - private fun updateUI(result: ScanResult) { - when (result.disposition) { - is ScanDisposition.Start -> { - resetUI() - } - - is ScanDisposition.Detected -> { - binding.tvScanMessage.text = getString(R.string.selfie_text_face_detected) - } - - is ScanDisposition.Desired -> { - binding.tvScanMessage.text = - getString(R.string.selfie_text_selfie_scan_completed) - } - - is ScanDisposition.Undesired -> {} - is ScanDisposition.Completed -> {} - is ScanDisposition.Timeout, null -> { - // noOP - } - } - } - - private fun resetUI() { - binding.tvScanMessage.text = getString(R.string.selfie_text_scan_message) - } - - private fun uploadSelfie(bitmap: Bitmap) { - identityViewModel.uploadSelfieImage( - bitmap, - onSuccess = { submitSelfieAndUploadedDocuments(it) }, - onFailure = { navigateToErrorFragment(it) }, - onError = { navigateToApiResponseProblemFragment((it as ApiException).problem) } - ) - } - - private fun reportCameraInfoTelemetry(cameraInfo: CameraInfo) { - identityViewModel.reportTelemetry( - identityViewModel.analyticsRequestBuilder.cameraInfo(cameraInfo.sensorRotationDegrees) - ) - } - - private fun submitSelfieAndUploadedDocuments(file: FaluFile) { - val selfie = VerificationSelfieUpload( - UploadMethod.MANUAL, - file = file.id, - variance = 0F - ) - - val updateOptions = VerificationUpdateOptions(selfie = selfie) - - updateVerification(identityViewModel, updateOptions, R.id.fragment_selfie, onSuccess = { - selfie.camera = binding.viewCamera.cameraSettings - verificationRequest.selfie = selfie - attemptSelfieSubmission() - }) - } - - private fun onVerificationPage() { - faceScanViewModel.faceScanCompleteDisposition.observe(viewLifecycleOwner) { - if (it.disposition is ScanDisposition.Completed) { - // stop the analyzer - binding.viewCamera.stopAnalyzer() - binding.viewCamera.analyzers.clear() - - val output = it.output as FaceDetectionOutput - reportFaceScanSuccessfulTelemetry(output = output) - bindToUI(output) - } else if (it.disposition is ScanDisposition.Timeout) { - identityViewModel.reportTelemetry(identityViewModel.analyticsRequestBuilder.selfieScanTimeOut()) - - binding.viewCamera.stopAnalyzer() - binding.viewCamera.analyzers.clear() - - findNavController().navigate(R.id.action_global_fragment_selfie_capture_error) - } - } - } - - private fun attemptSelfieSubmission() { - identityViewModel.observeForVerificationResults(viewLifecycleOwner, - onSuccess = { verification -> - when { - verification.taxPinRequired -> { - findNavController().navigate( - R.id.action_global_fragment_tax_pin_verification, - verificationRequest.addToBundle() - ) - } - - else -> { - submitVerificationData(identityViewModel, R.id.fragment_selfie, verificationRequest) - } - } - }, - onError = { - navigateToApiResponseProblemFragment((it as ApiException).problem) - } - ) - } - - private fun reportFaceScanSuccessfulTelemetry(output: FaceDetectionOutput) { - identityViewModel.modifyAnalyticsDisposition( - disposition = AnalyticsDisposition(selfieModelScore = output.score) - ) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - companion object { - private const val THRESHOLD = 0.75f - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/support/SupportFragment.kt b/identity/src/main/java/io/falu/identity/support/SupportFragment.kt deleted file mode 100644 index df045c62..00000000 --- a/identity/src/main/java/io/falu/identity/support/SupportFragment.kt +++ /dev/null @@ -1,75 +0,0 @@ -package io.falu.identity.support - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import io.falu.core.exceptions.ApiException -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_SUPPORT -import io.falu.identity.api.models.Support -import io.falu.identity.databinding.FragmentSupportBinding -import io.falu.identity.utils.navigateToApiResponseProblemFragment - -internal class SupportFragment( - private val factory: ViewModelProvider.Factory -) : Fragment() { - private var _binding: FragmentSupportBinding? = null - private val binding get() = _binding!! - - private val viewModel: IdentityVerificationViewModel by activityViewModels { factory } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSupportBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewModel.reportTelemetry( - viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_SUPPORT) - ) - - viewModel.observeForVerificationResults( - viewLifecycleOwner, - onSuccess = { - val support = it.support!! - bindSupportViews(support) - }, - onError = { - navigateToApiResponseProblemFragment((it as ApiException).problem) - } - ) - } - - private fun bindSupportViews(support: Support) { - binding.tvSupportUrl.text = support.url - binding.viewSupportCall.setOnClickListener { - val intent = Intent(Intent.ACTION_DIAL) - intent.data = Uri.parse("tel:${support.phone}") - startActivity(intent) - } - binding.viewSupportEmail.setOnClickListener { - val intent = Intent(Intent.ACTION_SENDTO) - intent.data = Uri.parse("mailto:") - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(support.email)) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/ui/CountriesView.kt b/identity/src/main/java/io/falu/identity/ui/CountriesView.kt new file mode 100644 index 00000000..d8abc29c --- /dev/null +++ b/identity/src/main/java/io/falu/identity/ui/CountriesView.kt @@ -0,0 +1,173 @@ +package io.falu.identity.ui + +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.decode.SvgDecoder +import coil.request.ImageRequest +import io.falu.core.utils.toThrowable +import io.falu.identity.R +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.WorkspaceInfo +import io.falu.identity.api.models.country.Country +import io.falu.identity.api.models.country.SupportedCountry +import io.falu.identity.ui.theme.IdentityTheme +import software.tingle.api.ResourceResponse + +internal const val TAG_INPUT_ISSUING_COUNTRY = "input_issuing_country" + +@Composable +internal fun CountriesView( + response: ResourceResponse>?, + selected: SupportedCountry?, + onCountrySelected: (SupportedCountry) -> Unit = {} +) { + ObserveCountriesAndCompose(response, onError = {}) { supportedCountries -> + SupportedCountryViews(selected, supportedCountries, onCountrySelected) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SupportedCountryViews( + selected: SupportedCountry?, + supportedCountries: Array, + onCountrySelected: (SupportedCountry) -> Unit = {} +) { + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.content_padding_normal)) + ) { + Text(text = stringResource(R.string.document_selection_subtitle), style = MaterialTheme.typography.bodyMedium) + + Spacer(modifier = Modifier.size(dimensionResource(R.dimen.element_spacing_normal))) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + readOnly = true, + value = selected?.country?.name.orEmpty(), + onValueChange = {}, + label = { + Text( + text = stringResource(R.string.document_selection_hint_issuing_country), + color = MaterialTheme.colorScheme.onSurface + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded, + modifier = Modifier.menuAnchor(MenuAnchorType.SecondaryEditable) + ) + }, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ), + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + .semantics { + testTag = TAG_INPUT_ISSUING_COUNTRY + } + ) + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + supportedCountries.forEach { option -> + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(option.country.flag) + .decoderFactory(SvgDecoder.Factory()) + .build(), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Text( + text = option.country.name, + modifier = Modifier.padding( + start = dimensionResource(R.dimen.element_spacing_normal) + ) + ) + } + }, + onClick = { + expanded = false + onCountrySelected(option) + } + ) + } + } + } + } +} + +@Composable +private fun ObserveCountriesAndCompose( + response: ResourceResponse>?, + onError: (Throwable?) -> Unit, + onSuccess: @Composable (Array) -> Unit +) { + if (response != null && response.successful() && response.resource != null) { + onSuccess(response.resource!!) + } else { + onError(response?.toThrowable()) + } +} + +@Preview +@Composable +fun WelcomePreview() { + IdentityTheme { + IdentityVerificationHeader(Uri.EMPTY, WorkspaceInfo(name = "Showcases", country = "Kenya"), false) { + val country = SupportedCountry( + Country("ke", "Kenya", "https://cdn.tinglesoftware.com/statics/countries/flags/svg/ken.svg"), + documents = mutableListOf(IdentityDocumentType.PASSPORT) + ) + + SupportedCountryViews( + selected = country, + arrayOf(country) + ) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/ui/IdentityVerificationBaseScreen.kt b/identity/src/main/java/io/falu/identity/ui/IdentityVerificationBaseScreen.kt new file mode 100644 index 00000000..3b9692cc --- /dev/null +++ b/identity/src/main/java/io/falu/identity/ui/IdentityVerificationBaseScreen.kt @@ -0,0 +1,260 @@ +package io.falu.identity.ui + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +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.zIndex +import coil.compose.AsyncImage +import io.falu.core.utils.toThrowable +import io.falu.identity.ContractArgs +import io.falu.identity.R +import io.falu.identity.api.models.WorkspaceInfo +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.ui.theme.IdentityTheme +import io.falu.identity.viewModel.IdentityVerificationViewModel +import software.tingle.api.ResourceResponse + +@Composable +internal fun IdentityVerificationBaseScreen( + viewModel: IdentityVerificationViewModel, + contractArgs: ContractArgs, + navigateToSupport: () -> Unit = {}, + content: @Composable () -> Unit +) { + val response by viewModel.verification.observeAsState() + var workspace by remember { mutableStateOf(null) } + var liveMode by remember { mutableStateOf(null) } + + ObserveVerificationAndCompose(response, onError = {}) { verification -> + liveMode = verification.live + workspace = verification.workspace + } + + IdentityVerificationHeader( + logoUri = contractArgs.workspaceLogo, + workspace = workspace, + live = liveMode, + navigateToSupport = navigateToSupport, + content = content + ) +} + +@Composable +internal fun IdentityVerificationHeader( + logoUri: Uri, + workspace: WorkspaceInfo?, + live: Boolean?, + isSupportScreen: Boolean = false, + navigateToSupport: () -> Unit = {}, + content: @Composable () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = dimensionResource(id = R.dimen.content_padding_normal)), + verticalArrangement = Arrangement.Center + ) { + Box(contentAlignment = Alignment.TopCenter) { + WelcomeImage(logoUri = logoUri) + + // Information Card + Card( + shape = RoundedCornerShape(2.dp), + modifier = Modifier.padding(top = dimensionResource(id = R.dimen.content_padding_normal_2x)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.content_padding_normal_2x)) + .padding(bottom = dimensionResource(R.dimen.element_spacing_normal)) + ) { + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.element_spacing_normal))) + + // Workspace Name + if (workspace != null && workspace.name.isNotEmpty()) { + Text( + text = workspace.name, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + + // Identity Verification Title + Text( + text = stringResource(id = R.string.identity_verification_title_identity_verification) + .uppercase(), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + if (live == null || live) { + // Divider + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = dimensionResource(R.dimen.element_spacing_normal)) + .height(dimensionResource(R.dimen.element_spacing_normal_half)), + color = Color.LightGray + ) + } + if (live != null && !live) { + SandboxView() + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.content_padding_normal)) + .padding(bottom = dimensionResource(R.dimen.element_spacing_normal)) + ) { + content() + } + } + } + } + + if (!isSupportScreen) { + Footer(modifier = Modifier, navigateToSupport) + } + } +} + +@Composable +internal fun SandboxView() { + Row( + modifier = Modifier + .wrapContentHeight() + .padding(top = dimensionResource(R.dimen.element_spacing_normal)), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(1f) + .height(3.dp) + .background(Color(0xFFFFB100)) // Use Color in ARGB hex format + ) + + Box( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(dimensionResource(R.dimen.element_spacing_normal))) // Rounded corners + .background(Color(0xFFFFB100)) + ) { + Column( + modifier = Modifier + .padding(horizontal = dimensionResource(R.dimen.element_spacing_normal)) + .padding(vertical = dimensionResource(R.dimen.element_spacing_normal_quarter)), + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.identity_verification_text_mode_sandbox), + style = MaterialTheme.typography.labelSmall, + color = Color.White + ) + } + } + + Box( + modifier = Modifier + .weight(1f) + .height(3.dp) + .background(Color(0xFFFFB100)) + ) + } +} + +@Composable +internal fun WelcomeImage(logoUri: Uri) { + Box( + modifier = Modifier + .size(65.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .border(2.dp, Color.LightGray, CircleShape) + .shadow(elevation = 3.dp, shape = CircleShape) + .zIndex(1f) + ) { + AsyncImage( + model = logoUri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } +} + +@Composable +private fun Footer(modifier: Modifier, navigateToSupport: () -> Unit) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextButton(onClick = { navigateToSupport() }) { + Text( + text = stringResource(R.string.identity_verification_text_help_and_support), + color = MaterialTheme.colorScheme.secondary + ) + } + } +} + +@Composable +internal fun ObserveVerificationAndCompose( + response: ResourceResponse?, + onError: (Throwable?) -> Unit, + onSuccess: @Composable (Verification) -> Unit +) { + if (response != null && response.successful() && response.resource != null) { + onSuccess(response.resource!!) + } else { + onError(response?.toThrowable()) + } +} + +@Preview +@Composable +internal fun IdentityBasePreview() { + IdentityTheme { + IdentityVerificationHeader(Uri.EMPTY, WorkspaceInfo(name = "Showcases", country = "US"), false) {} + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/ui/LoadingButtonCompose.kt b/identity/src/main/java/io/falu/identity/ui/LoadingButtonCompose.kt new file mode 100644 index 00000000..8b37f84e --- /dev/null +++ b/identity/src/main/java/io/falu/identity/ui/LoadingButtonCompose.kt @@ -0,0 +1,39 @@ +package io.falu.identity.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import io.falu.identity.R + +@Composable +fun LoadingButton( + modifier: Modifier = Modifier, + text: String, + isLoading: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled && !isLoading, + modifier = modifier.fillMaxWidth() + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(dimensionResource(R.dimen.content_padding_normal)), + color = MaterialTheme.colorScheme.secondary, + strokeWidth = dimensionResource(R.dimen.element_spacing_normal_quarter), + trackColor = Color.LightGray + ) + } else { + Text(text = text) + } + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/ui/TextFieldError.kt b/identity/src/main/java/io/falu/identity/ui/TextFieldError.kt new file mode 100644 index 00000000..4cd8eabc --- /dev/null +++ b/identity/src/main/java/io/falu/identity/ui/TextFieldError.kt @@ -0,0 +1,25 @@ +package io.falu.identity.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import io.falu.identity.R + +@Composable +internal fun TextFieldError(error: String) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = error, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.content_padding_normal)) + .padding(top = dimensionResource(id = R.dimen.element_spacing_normal)), + color = MaterialTheme.colorScheme.error + ) + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/ui/theme/IdentityTheme.kt b/identity/src/main/java/io/falu/identity/ui/theme/IdentityTheme.kt new file mode 100644 index 00000000..188a741f --- /dev/null +++ b/identity/src/main/java/io/falu/identity/ui/theme/IdentityTheme.kt @@ -0,0 +1,213 @@ +package io.falu.identity.ui.theme + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.content.res.TypedArray +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.core.content.res.getColorOrThrow +import io.falu.identity.R +import java.lang.reflect.Method + +/** + * The theme attempts to read the them from the hosting app's context + */ +@Composable +internal fun IdentityTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val key = context.theme.key ?: context.theme + val layoutDirection = LocalLayoutDirection.current + + val themeParameters = remember(key) { + createTheme(context, layoutDirection) + } + + val hostingAppTypography = themeParameters.typography ?: MaterialTheme.typography + val hostingAppShapes = themeParameters.shapes ?: MaterialTheme.shapes + + MaterialTheme( + colorScheme = themeParameters.colorScheme ?: MaterialTheme.colorScheme, + shapes = hostingAppShapes + ) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onBackground, + content = content + ) + } +} + +/** + * Copied from Mdc3Theme.kt + */ +private fun createTheme( + context: Context, + layoutDirection: LayoutDirection, + readColorScheme: Boolean = true, + readTypography: Boolean = true +): ThemeParameters { + return context.obtainStyledAttributes(R.styleable.ThemeAdapterMaterialTheme).use { ta -> + val colorScheme: ColorScheme? = if (readColorScheme) { + val primary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorPrimary) + val onPrimary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnPrimary) + val primaryInverse = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorPrimaryInverse) + val primaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorPrimaryContainer) + val onPrimaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnPrimaryContainer) + val secondary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorSecondary) + val onSecondary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnSecondary) + val secondaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorSecondaryContainer) + val onSecondaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnSecondaryContainer) + val tertiary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorTertiary) + val onTertiary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnTertiary) + val tertiaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorTertiaryContainer) + val onTertiaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnTertiaryContainer) + val background = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_android_colorBackground) + val onBackground = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnBackground) + val surface = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorSurface) + val onSurface = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnSurface) + val surfaceVariant = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorSurfaceVariant) + val onSurfaceVariant = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnSurfaceVariant) + val elevationOverlay = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_elevationOverlayColor) + val surfaceInverse = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorSurfaceInverse) + val onSurfaceInverse = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnSurfaceInverse) + val outline = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOutline) + val outlineVariant = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOutlineVariant) + val error = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorError) + val onError = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnError) + val errorContainer = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorErrorContainer) + val onErrorContainer = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnErrorContainer) + val scrimBackground = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_scrimBackground) + + val isLightTheme = ta.getBoolean(R.styleable.ThemeAdapterMaterialTheme_isLightTheme, true) + + if (isLightTheme) { + lightColorScheme( + primary = primary, + onPrimary = onPrimary, + inversePrimary = primaryInverse, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + surfaceTint = elevationOverlay, + inverseSurface = surfaceInverse, + inverseOnSurface = onSurfaceInverse, + outline = outline, + outlineVariant = outlineVariant, + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + scrim = scrimBackground + ) + } else { + darkColorScheme( + primary = primary, + onPrimary = onPrimary, + inversePrimary = primaryInverse, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + surfaceTint = elevationOverlay, + inverseSurface = surfaceInverse, + inverseOnSurface = onSurfaceInverse, + outline = outline, + outlineVariant = outlineVariant, + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + scrim = scrimBackground + ) + } + } else { + null + } + // Extract typography if readTypography is true + + // You can extract shapes similarly if needed or return default shapes + val shapes: Shapes = Shapes() + + ThemeParameters(colorScheme, null, shapes) + } +} + +/** + * This class contains the individual components of a [MaterialTheme]: [ColorScheme] and + * [Typography]. + */ +private data class ThemeParameters( + val colorScheme: ColorScheme?, + val typography: Typography?, + val shapes: Shapes? +) + +/** + * Copied from Mdc3Theme.kt + */ +private inline val Resources.Theme.key: Any? + @SuppressLint("PrivateApi") + get() { + if (!sThemeGetKeyMethodFetched) { + try { + @Suppress("SoonBlockedPrivateApi") + sThemeGetKeyMethod = Resources.Theme::class.java.getDeclaredMethod("getKey") + .apply { isAccessible = true } + } catch (e: ReflectiveOperationException) { + // Failed to retrieve Theme.getKey method + } + sThemeGetKeyMethodFetched = true + } + if (sThemeGetKeyMethod != null) { + return try { + sThemeGetKeyMethod?.invoke(this) + } catch (e: ReflectiveOperationException) { + // Failed to invoke Theme.getKey() + } + } + return null + } + +private fun TypedArray.parseColor( + index: Int, + fallbackColor: Color = Color.Unspecified +): Color = if (hasValue(index)) Color(getColorOrThrow(index)) else fallbackColor + +private var sThemeGetKeyMethodFetched = false +private var sThemeGetKeyMethod: Method? = null \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/utils/Anchors.kt b/identity/src/main/java/io/falu/identity/utils/Anchor.kt similarity index 100% rename from identity/src/main/java/io/falu/identity/utils/Anchors.kt rename to identity/src/main/java/io/falu/identity/utils/Anchor.kt diff --git a/identity/src/main/java/io/falu/identity/utils/BitmapExtensions.kt b/identity/src/main/java/io/falu/identity/utils/BitmapExtensions.kt index 7c650ac9..f2aae163 100644 --- a/identity/src/main/java/io/falu/identity/utils/BitmapExtensions.kt +++ b/identity/src/main/java/io/falu/identity/utils/BitmapExtensions.kt @@ -87,9 +87,9 @@ internal fun Bitmap.crop(rect: Rect): Bitmap { require(rect.left < rect.right && rect.top < rect.bottom) { "Cannot crop negative values" } require( rect.left >= 0 && - rect.top >= 0 && - rect.bottom <= this.height && - rect.right <= this.width + rect.top >= 0 && + rect.bottom <= this.height && + rect.right <= this.width ) { "Invalid dimensions for crop" } @@ -107,7 +107,7 @@ internal fun Bitmap.toSize() = Size(this.width, this.height) */ @CheckResult internal fun Bitmap.withBoundingBox(bounds: Rect): Bitmap { - val bitmap = copy(config, true) + val bitmap = copy(config!!, true) val canvas = Canvas(bitmap) Paint().apply { diff --git a/identity/src/main/java/io/falu/identity/utils/Extensions.kt b/identity/src/main/java/io/falu/identity/utils/Extensions.kt index 53b5aec0..1290b32c 100644 --- a/identity/src/main/java/io/falu/identity/utils/Extensions.kt +++ b/identity/src/main/java/io/falu/identity/utils/Extensions.kt @@ -1,13 +1,18 @@ package io.falu.identity.utils +import android.app.Activity import android.content.Context +import android.content.ContextWrapper +import android.content.Intent import android.graphics.Rect +import android.net.Uri +import android.provider.Settings +import android.renderscript.RenderScript import android.text.TextUtils import android.util.Size import android.view.View import androidx.annotation.CheckResult import androidx.annotation.RestrictTo -import io.falu.identity.R import io.falu.identity.ai.DocumentOption import io.falu.identity.scan.ScanDisposition import software.tingle.api.HttpApiResponseProblem @@ -18,14 +23,14 @@ import kotlin.math.roundToInt internal fun HttpApiResponseProblem.getErrorDescription(context: Context): String { if (errors.isNullOrEmpty()) { val desc = description ?: code - return context.getString(R.string.error_description_http_error, desc) + return desc.orEmpty() } var desc = "" for (errors in errors!!.values) { desc = TextUtils.join("\n", errors) } - return context.getString(R.string.error_description_http_error, desc) + return desc } /** @@ -97,8 +102,30 @@ internal fun Size.maxAspectRatio(ratio: Float): Size { /***/ internal fun DocumentOption.matches(type: ScanDisposition.DocumentScanType): Boolean { return this == DocumentOption.DL_BACK && type == ScanDisposition.DocumentScanType.DL_BACK || - this == DocumentOption.DL_FRONT && type == ScanDisposition.DocumentScanType.DL_FRONT || - this == DocumentOption.ID_BACK && type == ScanDisposition.DocumentScanType.ID_BACK || - this == DocumentOption.ID_FRONT && type == ScanDisposition.DocumentScanType.ID_FRONT || - this == DocumentOption.PASSPORT && type == ScanDisposition.DocumentScanType.PASSPORT -} \ No newline at end of file + this == DocumentOption.DL_FRONT && type == ScanDisposition.DocumentScanType.DL_FRONT || + this == DocumentOption.ID_BACK && type == ScanDisposition.DocumentScanType.ID_BACK || + this == DocumentOption.ID_FRONT && type == ScanDisposition.DocumentScanType.ID_FRONT || + this == DocumentOption.PASSPORT && type == ScanDisposition.DocumentScanType.PASSPORT +} + +/***/ +internal fun Uri.isHttp() = this.scheme!!.startsWith("http") + +/***/ +internal fun Context.getActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null +} + +/***/ +fun Context.openAppSettings() { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null) + ) + startActivity(intent) +} + +/***/ +internal fun Context.getRenderScript() = RenderScript.create(this) \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/utils/FragmentExtensions.kt b/identity/src/main/java/io/falu/identity/utils/FragmentExtensions.kt deleted file mode 100644 index ca31494b..00000000 --- a/identity/src/main/java/io/falu/identity/utils/FragmentExtensions.kt +++ /dev/null @@ -1,139 +0,0 @@ -@file:Suppress("deprecation") // ktlint-disable annotation -package io.falu.identity.utils - -import android.content.Context -import android.renderscript.RenderScript -import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import com.google.android.material.datepicker.MaterialDatePicker -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.falu.core.exceptions.ApiException -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.api.models.verification.VerificationUpdateOptions -import io.falu.identity.api.models.verification.VerificationUploadRequest -import io.falu.identity.capture.scan.DocumentScanViewModel -import io.falu.identity.error.ErrorFragment.Companion.navigateWithApiExceptions -import io.falu.identity.error.ErrorFragment.Companion.navigateWithFailure -import io.falu.identity.error.ErrorFragment.Companion.navigateWithRequirementErrors -import software.tingle.api.HttpApiResponseProblem -import java.io.File - -internal fun Fragment.updateVerification( - viewModel: IdentityVerificationViewModel, - updateOptions: VerificationUpdateOptions, - @IdRes source: Int, - onSuccess: (() -> Unit) -) { - viewModel.updateVerification(updateOptions, - onSuccess = { - when { - else -> { - onSuccess() - } - } - }, - onError = { - navigateToApiResponseProblemFragment((it as ApiException).problem) - }, - onFailure = { - navigateToErrorFragment(it) - }) -} - -internal fun Fragment.submitVerificationData( - viewModel: IdentityVerificationViewModel, - @IdRes source: Int, - verificationUploadRequest: VerificationUploadRequest -) { - viewModel.submitVerificationData( - verificationUploadRequest, - onSuccess = { - when { - it.hasRequirementErrors -> { - findNavController().navigateWithRequirementErrors( - requireContext(), - source, - it.requirements.errors.first() - ) - } - - it.submitted -> { - findNavController().navigate(R.id.action_global_fragment_confirmation) - } - } - }, - onError = { - navigateToApiResponseProblemFragment((it as ApiException).problem) - }, - onFailure = { - navigateToErrorFragment(it) - } - ) -} - -/** - * The destination is [ErrorFragment] - */ -internal fun Fragment.navigateToErrorFragment(it: Throwable) { - findNavController().navigateWithFailure(requireContext(), it) -} - -/** - * - */ -internal fun Fragment.navigateToApiResponseProblemFragment(it: HttpApiResponseProblem?) { - findNavController().navigateWithApiExceptions(requireContext(), it) -} - -internal fun Fragment.loadDocumentDetectionModel( - identityViewModel: IdentityVerificationViewModel, - documentScanViewModel: DocumentScanViewModel, - threshold: Float, - onLoad: (File) -> Unit -) { - identityViewModel.documentDetectorModelFile.observe(viewLifecycleOwner) { - if (it != null) { - documentScanViewModel.initialize(it, threshold) - onLoad(it) - } - } -} - -/***/ -internal fun Fragment.showDatePickerDialog(onPositiveClickListener: (Long) -> Unit) { - val datePicker = MaterialDatePicker.Builder - .datePicker() - .setSelection(MaterialDatePicker.todayInUtcMilliseconds()) - .build() - datePicker.addOnPositiveButtonClickListener { - onPositiveClickListener(it) - } - datePicker.show(childFragmentManager, "MaterialDatePicker") -} - -/***/ -internal fun Context.showDialog( - title: String? = null, - message: String? = null, - positiveButton: Pair Unit>, - negativeButton: Pair Unit>? = null -) { - val dialog = MaterialAlertDialogBuilder(this).setMessage(message) - .setTitle(title) - .setPositiveButton(positiveButton.first) { _, _ -> - positiveButton.second() - } - - if (negativeButton != null) { - dialog.setNegativeButton(negativeButton.first) { _, _ -> - negativeButton.second - } - } - - dialog.show() -} - -/***/ -internal fun Context.getRenderScript() = RenderScript.create(this) \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/utils/IdentityImageHandler.kt b/identity/src/main/java/io/falu/identity/utils/IdentityImageHandler.kt new file mode 100644 index 00000000..06090935 --- /dev/null +++ b/identity/src/main/java/io/falu/identity/utils/IdentityImageHandler.kt @@ -0,0 +1,79 @@ +package io.falu.identity.utils + +import android.content.Context +import android.net.Uri +import androidx.activity.result.ActivityResultCaller +import androidx.lifecycle.SavedStateHandle + +internal class IdentityImageHandler { + + private lateinit var imageCaptureFront: ImageCapture + private lateinit var imageCaptureBack: ImageCapture + private lateinit var frontImagePicker: ImagePicker + private lateinit var backImagePicker: ImagePicker + + /** + * + */ + fun registerActivityResultCaller( + caller: ActivityResultCaller, + savedStateHandle: SavedStateHandle, + utils: FileUtils, + onFrontImageCaptured: (Uri) -> Unit, + onBackImageCaptured: (Uri) -> Unit, + onFrontImagePicked: (Uri) -> Unit, + onBackImagePicked: (Uri) -> Unit + ) { + imageCaptureFront = + ImageCapture( + caller, + utils, + savedStateHandle, + KEY_FRONT_IMAGE_URI, + onFrontImageCaptured + ) + imageCaptureBack = + ImageCapture( + caller, + utils, + savedStateHandle, + KEY_BACK_IMAGE_URI, + onBackImageCaptured + ) + frontImagePicker = ImagePicker(caller, onFrontImagePicked) + backImagePicker = ImagePicker(caller, onBackImagePicked) + } + + /** + * + */ + fun captureImageFront(context: Context) { + imageCaptureFront.captureImage(context) + } + + /** + * + */ + fun captureImageBack(context: Context) { + imageCaptureBack.captureImage(context) + } + + /** + * Pick an image for front. + */ + fun pickImageFront() { + frontImagePicker.pickImage() + } + + /** + * Pick an image for back. + */ + fun pickImageBack() { + backImagePicker.pickImage() + } + + internal companion object { + const val KEY_FRONT_IMAGE_URI = ":front" + const val KEY_BACK_IMAGE_URI = ":back" + } +} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/ImageCapture.kt b/identity/src/main/java/io/falu/identity/utils/ImageCapture.kt similarity index 57% rename from identity/src/main/java/io/falu/identity/capture/ImageCapture.kt rename to identity/src/main/java/io/falu/identity/utils/ImageCapture.kt index 237e84a8..d8230ee1 100644 --- a/identity/src/main/java/io/falu/identity/capture/ImageCapture.kt +++ b/identity/src/main/java/io/falu/identity/utils/ImageCapture.kt @@ -1,4 +1,4 @@ -package io.falu.identity.capture +package io.falu.identity.utils import android.app.Activity import android.content.Context @@ -9,7 +9,6 @@ import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.SavedStateHandle -import io.falu.identity.utils.FileUtils /** * Use camera to capture an image @@ -21,12 +20,11 @@ internal class ImageCapture( uriId: String, onImageCaptured: ((Uri) -> Unit) ) { - private val capturedImageUri: Uri = - stateHandle.get(uriId) ?: run { - val newUri = utils.internalFileUri - stateHandle[uriId] = newUri - newUri - } + private val capturedImageUri: Uri = stateHandle.get(uriId) ?: run { + val newUri = utils.internalFileUri + stateHandle[uriId] = newUri + newUri + } private val cameraLauncher: ActivityResultLauncher = caller.registerForActivityResult( ActivityResultContracts.StartActivityForResult() @@ -37,12 +35,14 @@ internal class ImageCapture( } internal fun captureImage(context: Context) { - cameraLauncher.launch(Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { captureImageIntent -> - // ensure that there's a camera activity to handle the intent - captureImageIntent.resolveActivity(context.packageManager).also { - // create the File where the photo should go - captureImageIntent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri) + cameraLauncher.launch( + Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { captureImageIntent -> + // ensure that there's a camera activity to handle the intent + captureImageIntent.resolveActivity(context.packageManager).also { + // create the File where the photo should go + captureImageIntent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri) + } } - }) + ) } } \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/ImagePicker.kt b/identity/src/main/java/io/falu/identity/utils/ImagePicker.kt similarity index 63% rename from identity/src/main/java/io/falu/identity/capture/ImagePicker.kt rename to identity/src/main/java/io/falu/identity/utils/ImagePicker.kt index 482ad1fc..0ea34c7d 100644 --- a/identity/src/main/java/io/falu/identity/capture/ImagePicker.kt +++ b/identity/src/main/java/io/falu/identity/utils/ImagePicker.kt @@ -1,18 +1,18 @@ -package io.falu.identity.capture +package io.falu.identity.utils import android.net.Uri +import androidx.activity.result.ActivityResultCaller import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment /** * Select image from the local gallery */ internal class ImagePicker( - fragment: Fragment, + activityResultCaller: ActivityResultCaller, onImageSelected: ((Uri) -> Unit) ) { private val launcher = - fragment.registerForActivityResult(ActivityResultContracts.GetContent()) { + activityResultCaller.registerForActivityResult(ActivityResultContracts.GetContent()) { it?.let { uri -> onImageSelected(uri) } diff --git a/identity/src/main/java/io/falu/identity/utils/IntentExtensions.kt b/identity/src/main/java/io/falu/identity/utils/IntentExtensions.kt index 7380e238..f7217493 100644 --- a/identity/src/main/java/io/falu/identity/utils/IntentExtensions.kt +++ b/identity/src/main/java/io/falu/identity/utils/IntentExtensions.kt @@ -12,7 +12,8 @@ inline fun Bundle.serializable(key: String): T? = whe } else -> { - @Suppress("DEPRECATION") getSerializable(key) as? T + @Suppress("DEPRECATION") + getSerializable(key) as? T } } @@ -22,7 +23,8 @@ inline fun Intent.serializable(key: String): T? = whe } else -> { - @Suppress("DEPRECATION") getSerializableExtra(key) as? T + @Suppress("DEPRECATION") + getSerializableExtra(key) as? T } } @@ -32,7 +34,8 @@ inline fun Intent.parcelable(key: String): T? = when { } else -> { - @Suppress("DEPRECATION") getParcelableExtra(key) as? T + @Suppress("DEPRECATION") + getParcelableExtra(key) as? T } } @@ -42,6 +45,7 @@ inline fun Bundle.parcelable(key: String): T? = when { } else -> { - @Suppress("DEPRECATION") getParcelable(key) as? T + @Suppress("DEPRECATION") + getParcelable(key) as? T } } \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/verification/IdentificationVerificationFragment.kt b/identity/src/main/java/io/falu/identity/verification/IdentificationVerificationFragment.kt deleted file mode 100644 index 43f2b167..00000000 --- a/identity/src/main/java/io/falu/identity/verification/IdentificationVerificationFragment.kt +++ /dev/null @@ -1,178 +0,0 @@ -package io.falu.identity.verification - -import android.os.Bundle -import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.core.exceptions.ApiException -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.verification.Gender -import io.falu.identity.api.models.verification.VerificationIdNumberUpload -import io.falu.identity.api.models.verification.VerificationUpdateOptions -import io.falu.identity.api.models.verification.VerificationUploadRequest -import io.falu.identity.databinding.FragmentIdentificationVerificationBinding -import io.falu.identity.documents.DocumentSelectionFragment -import io.falu.identity.utils.navigateToApiResponseProblemFragment -import io.falu.identity.utils.serializable -import io.falu.identity.utils.showDatePickerDialog -import io.falu.identity.utils.showDialog -import io.falu.identity.utils.submitVerificationData -import io.falu.identity.utils.updateVerification -import java.util.Date - -internal class IdentificationVerificationFragment(factory: ViewModelProvider.Factory) : Fragment() { - - private var _binding: FragmentIdentificationVerificationBinding? = null - private val binding get() = _binding!! - - private val viewModel: IdentityVerificationViewModel by activityViewModels { factory } - - private lateinit var identityDocumentType: IdentityDocumentType - private var dateOfBirth: Date? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentIdentificationVerificationBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - identityDocumentType = requireArguments() - .serializable(DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE)!! - - val genderAdapter = ArrayAdapter( - requireContext(), - R.layout.dropdown_menu_popup_item, - Gender.entries.map { getString(it.desc) }) - - binding.inputGender.setAdapter(genderAdapter) - binding.inputGender.setText(genderAdapter.getItem(0), false) - binding.viewBirthday.setOnClickListener { - showDatePickerDialog { - dateOfBirth = Date(it) - binding.tvBirthday.text = DateUtils.formatDateTime(requireContext(), it, DateUtils.FORMAT_SHOW_DATE) - } - } - binding.buttonContinue.text = getString(R.string.button_continue) - binding.buttonContinue.setOnClickListener { - verify() - } - } - - private fun verify() { - val identityUpload = idNumberUpload ?: return - val request = VerificationUploadRequest(idNumber = identityUpload) - - binding.buttonContinue.showProgress() - updateVerification(request) - } - - private fun updateVerification(request: VerificationUploadRequest) { - val updateOptions = VerificationUpdateOptions(idNumber = request.idNumber) - - updateVerification( - viewModel, - updateOptions, - 0, - onSuccess = { attemptIdNumberSubmission(verificationRequest = request) }) - } - - private fun attemptIdNumberSubmission( - @IdRes source: Int = 0, - verificationRequest: VerificationUploadRequest - ) { - viewModel.observeForVerificationResults( - viewLifecycleOwner, - onSuccess = { verification -> - when { - verification.taxPinRequired -> { - findNavController().navigate( - R.id.action_global_fragment_tax_pin_verification, - verificationRequest.addToBundle() - ) - } - - else -> { - submitVerificationData(viewModel, source, verificationRequest) - } - } - }, - onError = { - navigateToApiResponseProblemFragment((it as ApiException).problem) - } - ) - } - - private val idNumberUpload: VerificationIdNumberUpload? - get() { - if (!isValidDocumentNumber) { - binding.inputLayoutDocumentNumber.isErrorEnabled = true - binding.inputLayoutDocumentNumber.error = - getString(R.string.document_verification_error_invalid_document_number) - return null - } - binding.inputLayoutDocumentNumber.isErrorEnabled = false - - if (!isValidFirstName) { - binding.inputLayoutFirstName.isErrorEnabled = true - binding.inputLayoutFirstName.error = getString(R.string.document_verification_error_invalid_first_name) - return null - } - binding.inputLayoutFirstName.isErrorEnabled = false - - if (!isValidLastName) { - binding.inputLayoutLastName.isErrorEnabled = true - binding.inputLayoutLastName.error = getString(R.string.document_verification_error_invalid_last_name) - return null - } - binding.inputLayoutLastName.isErrorEnabled = false - - if (dateOfBirth == null) { - requireContext().showDialog( - message = getString(R.string.document_verification_error_birthday), - positiveButton = Pair(getString(android.R.string.ok)) {} - ) - return null - } - - return VerificationIdNumberUpload( - type = identityDocumentType, - number = binding.inputDocumentNumber.text.toString(), - firstName = binding.inputFirstName.text.toString(), - lastName = binding.inputLastName.text.toString(), - birthday = dateOfBirth ?: Date(), - sex = binding.inputGender.text.toString().lowercase() - ) - } - - private val isValidDocumentNumber: Boolean - get() { - val documentNumber = binding.inputDocumentNumber.text.toString() - return documentNumber.isNotEmpty() && documentNumber.length > 5 - } - - private val isValidFirstName: Boolean - get() { - val firstName = binding.inputFirstName.text.toString() - return firstName.isNotEmpty() - } - - private val isValidLastName: Boolean - get() { - val firstName = binding.inputLastName.text.toString() - return firstName.isNotEmpty() - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/verification/TaxPinVerificationFragment.kt b/identity/src/main/java/io/falu/identity/verification/TaxPinVerificationFragment.kt deleted file mode 100644 index cb8688a1..00000000 --- a/identity/src/main/java/io/falu/identity/verification/TaxPinVerificationFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -package io.falu.identity.verification - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.api.models.verification.VerificationTaxPinUpload -import io.falu.identity.api.models.verification.VerificationUpdateOptions -import io.falu.identity.api.models.verification.VerificationUploadRequest -import io.falu.identity.databinding.FragmentTaxPinVerificationBinding -import io.falu.identity.utils.submitVerificationData -import io.falu.identity.utils.updateVerification - -internal class TaxPinVerificationFragment(factory: ViewModelProvider.Factory) : Fragment() { - - private var _binding: FragmentTaxPinVerificationBinding? = null - private val binding get() = _binding!! - - private val viewModel: IdentityVerificationViewModel by activityViewModels { factory } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentTaxPinVerificationBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val verificationRequest = - requireNotNull(VerificationUploadRequest.getFromBundle(requireArguments())) { - "Verification upload request is null" - } - - binding.buttonContinue.text = getString(R.string.button_continue) - binding.buttonContinue.setOnClickListener { - verify(verificationRequest) - } - } - - private fun verify(request: VerificationUploadRequest) { - val taxPinUpload = taxPinUpload ?: return - request.taxPin = taxPinUpload - - binding.buttonContinue.showProgress() - updateVerification(request) - } - - private fun updateVerification(request: VerificationUploadRequest) { - val updateOptions = VerificationUpdateOptions(taxPin = request.taxPin) - - updateVerification( - viewModel, - updateOptions, - 0, - onSuccess = { submitVerificationData(viewModel, 0, request) }) - } - - private val taxPinUpload: VerificationTaxPinUpload? - get() { - if (!isValidPin) { - binding.inputLayoutTaxPin.isErrorEnabled = true - binding.inputLayoutTaxPin.error = - getString(R.string.tax_pin_verification_error_invalid_tax_pin_number) - return null - } - - binding.inputLayoutTaxPin.isErrorEnabled = false - - val taxPin = binding.inputTaxPin.text.toString() - - return VerificationTaxPinUpload(value = taxPin) - } - - private val isValidPin: Boolean - get() { - val taxPin = binding.inputTaxPin.text.toString() - return taxPin.isNotEmpty() - } -} \ No newline at end of file diff --git a/identity/src/main/java/io/falu/identity/capture/scan/DocumentScanViewModel.kt b/identity/src/main/java/io/falu/identity/viewModel/DocumentScanViewModel.kt similarity index 68% rename from identity/src/main/java/io/falu/identity/capture/scan/DocumentScanViewModel.kt rename to identity/src/main/java/io/falu/identity/viewModel/DocumentScanViewModel.kt index 08da14c5..bfb2cefd 100644 --- a/identity/src/main/java/io/falu/identity/capture/scan/DocumentScanViewModel.kt +++ b/identity/src/main/java/io/falu/identity/viewModel/DocumentScanViewModel.kt @@ -1,29 +1,37 @@ -package io.falu.identity.capture.scan +package io.falu.identity.viewModel import android.util.Log import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.savedstate.SavedStateRegistryOwner import io.falu.identity.ai.DocumentDetectionOutput +import io.falu.identity.ai.DocumentDispositionMachine import io.falu.identity.analytics.ModelPerformanceMonitor +import io.falu.identity.api.models.verification.VerificationCapture +import io.falu.identity.scan.DocumentScanner import io.falu.identity.scan.IdentityResult import io.falu.identity.scan.ProvisionalResult +import io.falu.identity.scan.ScanDisposition import io.falu.identity.scan.ScanResult import io.falu.identity.scan.ScanResultCallback +import io.falu.identity.utils.toFraction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.io.File +import org.joda.time.DateTime import kotlin.coroutines.CoroutineContext -internal class DocumentScanViewModel(private val performanceMonitor: ModelPerformanceMonitor) : ViewModel(), - ScanResultCallback, CoroutineScope { +internal class DocumentScanViewModel(private val performanceMonitor: ModelPerformanceMonitor) : + ViewModel(), + ScanResultCallback, + CoroutineScope { override val coroutineContext: CoroutineContext get() = Job() + Dispatchers.IO @@ -45,10 +53,34 @@ internal class DocumentScanViewModel(private val performanceMonitor: ModelPerfor /** * */ - internal var scanner: DocumentScanner? = null + private lateinit var scanner: DocumentScanner - internal fun initialize(model: File, threshold: Float) { - scanner = DocumentScanner(model, threshold, performanceMonitor, this) + internal fun initializeScanner(scanner: DocumentScanner) { + this.scanner = scanner + this.scanner.callbacks = this + } + + internal fun startScan( + owner: LifecycleOwner, + capture: VerificationCapture, + scanType: ScanDisposition.DocumentScanType + ) { + scanner.requireCameraView().bindLifecycle(owner) + scanner.requireCameraView().startAnalyzer() + + scanner.disposition = null + + val machine = DocumentDispositionMachine( + timeout = DateTime.now().plusMillis(capture.timeout), + iou = capture.blur?.iou?.toFraction() ?: 0.95f, + requiredTime = capture.blur?.duration?.div(1000) ?: 5 + ) + scanner.disposition = ScanDisposition.Start(scanType, machine) + } + + internal fun stopScan(owner: LifecycleOwner) { + scanner.requireCameraView().stopAnalyzer() + scanner.requireCameraView().unbindFromLifecycle(owner) } override fun onScanComplete(result: IdentityResult) { @@ -65,7 +97,7 @@ internal class DocumentScanViewModel(private val performanceMonitor: ModelPerfor override fun onProgress(result: ProvisionalResult) { Log.d(TAG, "Scan in progress: ${result.disposition}") - scanner?.changeDisposition(result.disposition) { + scanner.changeDisposition(result.disposition) { if (it) { _documentScanDisposition.update { current -> current.modify(disposition = result.disposition) diff --git a/identity/src/main/java/io/falu/identity/selfie/FaceScanViewModel.kt b/identity/src/main/java/io/falu/identity/viewModel/FaceScanViewModel.kt similarity index 71% rename from identity/src/main/java/io/falu/identity/selfie/FaceScanViewModel.kt rename to identity/src/main/java/io/falu/identity/viewModel/FaceScanViewModel.kt index 37e59779..735872bf 100644 --- a/identity/src/main/java/io/falu/identity/selfie/FaceScanViewModel.kt +++ b/identity/src/main/java/io/falu/identity/viewModel/FaceScanViewModel.kt @@ -1,16 +1,21 @@ -package io.falu.identity.selfie +package io.falu.identity.viewModel import android.util.Log import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.savedstate.SavedStateRegistryOwner import io.falu.identity.ai.FaceDetectionOutput +import io.falu.identity.ai.FaceDispositionMachine import io.falu.identity.analytics.ModelPerformanceMonitor +import io.falu.identity.api.models.verification.VerificationCapture +import io.falu.identity.scan.FaceScanner import io.falu.identity.scan.IdentityResult import io.falu.identity.scan.ProvisionalResult +import io.falu.identity.scan.ScanDisposition import io.falu.identity.scan.ScanResult import io.falu.identity.scan.ScanResultCallback import kotlinx.coroutines.CoroutineScope @@ -19,11 +24,13 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.io.File +import org.joda.time.DateTime import kotlin.coroutines.CoroutineContext -internal class FaceScanViewModel(private val performanceMonitor: ModelPerformanceMonitor) : ViewModel(), - ScanResultCallback, CoroutineScope { +internal class FaceScanViewModel(private val performanceMonitor: ModelPerformanceMonitor) : + ViewModel(), + ScanResultCallback, + CoroutineScope { override val coroutineContext: CoroutineContext get() = Job() + Dispatchers.IO @@ -42,11 +49,26 @@ internal class FaceScanViewModel(private val performanceMonitor: ModelPerformanc val faceScanCompleteDisposition: LiveData get() = _faceScanCompleteDisposition.asLiveData(Dispatchers.Main) - /***/ - internal var scanner: FaceScanner? = null + private lateinit var scanner: FaceScanner - internal fun initialize(model: File, threshold: Float) { - scanner = FaceScanner(model, threshold, performanceMonitor, this) + internal fun initializeScanner(scanner: FaceScanner) { + this.scanner = scanner + this.scanner.callbacks = this + } + + internal fun startScan(owner: LifecycleOwner, capture: VerificationCapture) { + scanner.requireCameraView().bindLifecycle(owner) + scanner.requireCameraView().startAnalyzer() + + scanner.disposition = null + + val machine = FaceDispositionMachine(timeout = DateTime.now().plusMillis(capture.timeout)) + scanner.disposition = ScanDisposition.Start(ScanDisposition.DocumentScanType.SELFIE, machine) + } + + internal fun stopScan(owner: LifecycleOwner) { + scanner.requireCameraView().unbindFromLifecycle(owner) + scanner.requireCameraView().stopAnalyzer() } override fun onScanComplete(result: IdentityResult) { @@ -73,7 +95,7 @@ internal class FaceScanViewModel(private val performanceMonitor: ModelPerformanc _faceScanDisposition.update { ScanResult() } } - internal fun reportModelPerformance() { + private fun reportModelPerformance() { launch(Dispatchers.IO) { performanceMonitor.reportModelPerformance(FACE_DETECTOR_MODEL) } diff --git a/identity/src/main/java/io/falu/identity/IdentityVerificationViewModel.kt b/identity/src/main/java/io/falu/identity/viewModel/IdentityVerificationViewModel.kt similarity index 79% rename from identity/src/main/java/io/falu/identity/IdentityVerificationViewModel.kt rename to identity/src/main/java/io/falu/identity/viewModel/IdentityVerificationViewModel.kt index 2f6d7757..57db80ae 100644 --- a/identity/src/main/java/io/falu/identity/IdentityVerificationViewModel.kt +++ b/identity/src/main/java/io/falu/identity/viewModel/IdentityVerificationViewModel.kt @@ -1,9 +1,11 @@ -package io.falu.identity +package io.falu.identity.viewModel import android.graphics.Bitmap import android.net.Uri import android.util.Log import android.widget.ImageView +import androidx.activity.result.ActivityResultCaller +import androidx.annotation.VisibleForTesting import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData @@ -16,6 +18,7 @@ import io.falu.core.AnalyticsApiClient import io.falu.core.models.AnalyticsTelemetry import io.falu.core.models.FaluFile import io.falu.core.utils.toThrowable +import io.falu.identity.ContractArgs import io.falu.identity.analytics.AnalyticsDisposition import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder import io.falu.identity.analytics.ModelPerformanceMonitor @@ -29,7 +32,12 @@ import io.falu.identity.api.models.verification.Verification import io.falu.identity.api.models.verification.VerificationUpdateOptions import io.falu.identity.api.models.verification.VerificationUploadRequest import io.falu.identity.api.models.verification.VerificationUploadResult +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.navigation.SelfieDestination +import io.falu.identity.navigation.TaxPinDestination import io.falu.identity.utils.FileUtils +import io.falu.identity.utils.IdentityImageHandler +import io.falu.identity.utils.isHttp import io.falu.identity.utils.toWholeNumber import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -49,9 +57,11 @@ import kotlin.coroutines.CoroutineContext * View model that is shared across all fragments */ internal class IdentityVerificationViewModel( + private val saveStateHandle: SavedStateHandle, internal val apiClient: IdentityVerificationApiClient, internal val analyticsRequestBuilder: IdentityAnalyticsRequestBuilder, internal val contractArgs: ContractArgs, + internal val imageHandler: IdentityImageHandler, private val fileUtils: FileUtils ) : ViewModel(), CoroutineScope { @@ -81,7 +91,8 @@ internal class IdentityVerificationViewModel( /** * */ - private val _verification = MutableLiveData?>() + @VisibleForTesting + internal val _verification = MutableLiveData?>() val verification: LiveData?> get() = _verification @@ -89,7 +100,7 @@ internal class IdentityVerificationViewModel( * */ private val _supportedCountries = MutableLiveData>?>() - private val supportedCountries: LiveData>?> + val supportedCountries: LiveData>?> get() = _supportedCountries /** @@ -106,8 +117,6 @@ internal class IdentityVerificationViewModel( val faceDetectorModelFile: LiveData get() = _faceDetectorModelFile - private fun Uri.isHttp() = this.scheme!!.startsWith("http") - internal fun fetchVerification(modelRequired: Boolean = true, onFailure: (Throwable) -> Unit) { launch(Dispatchers.IO) { runCatching { @@ -119,7 +128,6 @@ internal class IdentityVerificationViewModel( if (it.successful() && it.resource != null) { val verification = it.resource!! if (modelRequired) { - downloadAIModel( verification.capture.models.document.url, _documentDetectorModelFile @@ -181,7 +189,8 @@ internal class IdentityVerificationViewModel( handleResponse( response, onSuccess = { onSuccess(it) }, - onError = { onError(it) }) + onError = { onError(it) } + ) }, onFailure = { Log.e(TAG, "Error uploading selfie image", it) @@ -269,7 +278,8 @@ internal class IdentityVerificationViewModel( handleResponse( response, onSuccess = { onSuccess(it) }, - onError = { onError(it) }) + onError = { onError(it) } + ) }, onFailure = { Log.e(TAG, "Error updating verification", it) @@ -293,7 +303,8 @@ internal class IdentityVerificationViewModel( handleResponse( response, onSuccess = onSuccess, - onError = { onError(it) }) + onError = { onError(it) } + ) }, onFailure = { Log.e(TAG, "Error submitting verification", it) @@ -303,6 +314,49 @@ internal class IdentityVerificationViewModel( } } + internal fun attemptDocumentSubmission( + fromRoute: String, + navActions: IdentityVerificationNavActions, + verification: Verification, + verificationRequest: VerificationUploadRequest + ) { + when { + verification.selfieRequired && fromRoute != SelfieDestination.ROUTE.route -> { + navActions.navigateToSelfie() + } + + verification.taxPinRequired && fromRoute != TaxPinDestination.ROUTE.route -> { + navActions.navigateToTaxPin() + } + + else -> { + submitVerificationData( + verificationRequest, + onSuccess = { + when { + it.hasRequirementErrors -> { + navActions.navigateToErrorWithRequirementErrors( + fromRoute, + it.requirements.errors.first() + ) + } + + it.submitted -> { + navActions.navigateToConfirmation() + } + } + }, + onFailure = { throwable -> + navActions.navigateToErrorWithFailure(throwable) + }, + onError = { throwable -> + navActions.navigateToErrorWithApiExceptions(throwable) + } + ) + } + } + } + internal fun fetchSupportedCountries() { launch(Dispatchers.IO) { runCatching { @@ -418,7 +472,6 @@ internal class IdentityVerificationViewModel( } fun reportSuccessfulVerificationTelemetry() { - launch(Dispatchers.IO) { analyticsDisposition.collectLatest { latest -> val cake = analyticsRequestBuilder.verificationSuccessful( @@ -453,6 +506,56 @@ internal class IdentityVerificationViewModel( return fileUtils.createFileFromInputStream(stream, fileName) } + fun registerActivityResultCaller( + activityResultCaller: ActivityResultCaller + ) { + imageHandler.registerActivityResultCaller( + activityResultCaller, + saveStateHandle, + fileUtils, + onFrontImageCaptured = { + uploadFile( + fileUtils.createFileFromUri(it, contractArgs.verificationId, "front"), + DocumentSide.FRONT, + UploadMethod.MANUAL, + contractArgs.verificationId, + onError = {}, + onFailure = {} + ) + }, + onBackImageCaptured = { + uploadFile( + fileUtils.createFileFromUri(it, contractArgs.verificationId, "back"), + DocumentSide.BACK, + UploadMethod.MANUAL, + contractArgs.verificationId, + onError = {}, + onFailure = {} + ) + }, + onFrontImagePicked = { + uploadFile( + fileUtils.createFileFromUri(it, contractArgs.verificationId, "front"), + DocumentSide.FRONT, + UploadMethod.UPLOAD, + contractArgs.verificationId, + onError = {}, + onFailure = {} + ) + }, + onBackImagePicked = { + uploadFile( + fileUtils.createFileFromUri(it, contractArgs.verificationId, "back"), + DocumentSide.BACK, + UploadMethod.UPLOAD, + contractArgs.verificationId, + onError = {}, + onFailure = {} + ) + } + ) + } + private suspend fun handleFailureResponse( throwable: Throwable, onFailure: (Throwable) -> Unit @@ -468,6 +571,7 @@ internal class IdentityVerificationViewModel( apiClient: () -> IdentityVerificationApiClient, analyticsRequestBuilder: () -> IdentityAnalyticsRequestBuilder, fileUtils: () -> FileUtils, + imageHandler: () -> IdentityImageHandler, contractArgs: () -> ContractArgs ): AbstractSavedStateViewModelFactory = object : AbstractSavedStateViewModelFactory(savedStateRegistryOwner, null) { @@ -477,9 +581,11 @@ internal class IdentityVerificationViewModel( handle: SavedStateHandle ): T { return IdentityVerificationViewModel( + handle, apiClient(), analyticsRequestBuilder(), contractArgs(), + imageHandler(), fileUtils() ) as T } diff --git a/identity/src/main/java/io/falu/identity/welcome/WelcomeFragment.kt b/identity/src/main/java/io/falu/identity/welcome/WelcomeFragment.kt deleted file mode 100644 index 000a7c5d..00000000 --- a/identity/src/main/java/io/falu/identity/welcome/WelcomeFragment.kt +++ /dev/null @@ -1,111 +0,0 @@ -package io.falu.identity.welcome - -import android.os.Bundle -import android.text.method.LinkMovementMethod -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.findNavController -import io.falu.core.exceptions.ApiException -import io.falu.identity.IdentityVerificationResult -import io.falu.identity.IdentityVerificationResultCallback -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.AnalyticsDisposition -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder.Companion.SCREEN_NAME_WELCOME -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.api.models.verification.VerificationUpdateOptions -import io.falu.identity.databinding.FragmentWelcomeBinding -import io.falu.identity.error.ErrorFragment.Companion.navigateWithDepletedAttempts -import io.falu.identity.utils.navigateToApiResponseProblemFragment -import io.falu.identity.utils.updateVerification -import software.tingle.api.HttpApiResponseProblem - -internal class WelcomeFragment( - private val factory: ViewModelProvider.Factory, - private val callback: IdentityVerificationResultCallback -) : - Fragment() { - private var _binding: FragmentWelcomeBinding? = null - private val binding get() = _binding!! - - private val viewModel: IdentityVerificationViewModel by activityViewModels { factory } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentWelcomeBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.reportTelemetry(viewModel.analyticsRequestBuilder.screenPresented(screenName = SCREEN_NAME_WELCOME)) - viewModel.observeForVerificationResults( - viewLifecycleOwner, - onSuccess = { onVerificationSuccessful(it) }, - onError = { onVerificationFailure((it as ApiException).problem) }) - - binding.buttonAccept.text = getString(R.string.welcome_button_accept) - binding.buttonAccept.setOnClickListener { - submitConsentData() - } - - binding.buttonDecline.setOnClickListener { - callback.onFinishWithResult(IdentityVerificationResult.Canceled) - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun onVerificationSuccessful(verification: Verification) { - viewModel.modifyAnalyticsDisposition(disposition = AnalyticsDisposition(selfie = verification.selfieRequired)) - - hideProgressView() - - val remainingAttempts = verification.remainingAttempts - - if (remainingAttempts != null && remainingAttempts == 0) { - findNavController().navigateWithDepletedAttempts(requireContext()) - return - } - - binding.tvWelcomeSubtitle.text = - getString( - R.string.welcome_subtitle, - verification.workspace.name.replaceFirstChar { it.uppercase() }) - binding.tvWelcomeBody.movementMethod = LinkMovementMethod.getInstance() - } - - private fun onVerificationFailure(error: HttpApiResponseProblem?) { - navigateToApiResponseProblemFragment(error) - } - - private fun submitConsentData() { - val updateOptions = VerificationUpdateOptions(consent = true) - - binding.buttonAccept.showProgress() - updateVerification( - viewModel, - updateOptions, - source = R.id.fragment_welcome, - onSuccess = { - binding.buttonAccept.showProgress() - findNavController().navigate(R.id.action_fragment_welcome_to_fragment_document_selection) - }) - } - - private fun hideProgressView() { - binding.progressView.visibility = View.GONE - binding.scrollView.visibility = View.VISIBLE - binding.viewButtons.visibility = View.VISIBLE - } -} \ No newline at end of file diff --git a/identity/src/main/res/drawable/ic_document_border.xml b/identity/src/main/res/drawable/ic_falu_document_border.xml similarity index 100% rename from identity/src/main/res/drawable/ic_document_border.xml rename to identity/src/main/res/drawable/ic_falu_document_border.xml diff --git a/identity/src/main/res/drawable/ic_email.xml b/identity/src/main/res/drawable/ic_falu_email.xml similarity index 100% rename from identity/src/main/res/drawable/ic_email.xml rename to identity/src/main/res/drawable/ic_falu_email.xml diff --git a/identity/src/main/res/drawable/ic_falu_selfie_border.xml b/identity/src/main/res/drawable/ic_falu_selfie_border.xml new file mode 100644 index 00000000..3f930a79 --- /dev/null +++ b/identity/src/main/res/drawable/ic_falu_selfie_border.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/identity/src/main/res/layout/activity_identity_verification.xml b/identity/src/main/res/layout/activity_identity_verification.xml deleted file mode 100644 index 54c0311d..00000000 --- a/identity/src/main/res/layout/activity_identity_verification.xml +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/divider_live_mode.xml b/identity/src/main/res/layout/divider_live_mode.xml deleted file mode 100644 index 0a10b47e..00000000 --- a/identity/src/main/res/layout/divider_live_mode.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/dropdown_menu_popup_item.xml b/identity/src/main/res/layout/dropdown_menu_popup_item.xml deleted file mode 100644 index dfdabb66..00000000 --- a/identity/src/main/res/layout/dropdown_menu_popup_item.xml +++ /dev/null @@ -1,7 +0,0 @@ - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_capture_side.xml b/identity/src/main/res/layout/fragment_capture_side.xml deleted file mode 100644 index 0fe4d7ee..00000000 --- a/identity/src/main/res/layout/fragment_capture_side.xml +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_confirmation.xml b/identity/src/main/res/layout/fragment_confirmation.xml deleted file mode 100644 index fd33e0c2..00000000 --- a/identity/src/main/res/layout/fragment_confirmation.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_document_capture_methods.xml b/identity/src/main/res/layout/fragment_document_capture_methods.xml deleted file mode 100644 index 25ec1157..00000000 --- a/identity/src/main/res/layout/fragment_document_capture_methods.xml +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_document_selection.xml b/identity/src/main/res/layout/fragment_document_selection.xml deleted file mode 100644 index 48d66b1b..00000000 --- a/identity/src/main/res/layout/fragment_document_selection.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_error.xml b/identity/src/main/res/layout/fragment_error.xml deleted file mode 100644 index 2166d28b..00000000 --- a/identity/src/main/res/layout/fragment_error.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_identification_verification.xml b/identity/src/main/res/layout/fragment_identification_verification.xml deleted file mode 100644 index 737faed9..00000000 --- a/identity/src/main/res/layout/fragment_identification_verification.xml +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_manual_capture.xml b/identity/src/main/res/layout/fragment_manual_capture.xml deleted file mode 100644 index 194a1bb4..00000000 --- a/identity/src/main/res/layout/fragment_manual_capture.xml +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_scan_capture.xml b/identity/src/main/res/layout/fragment_scan_capture.xml deleted file mode 100644 index 6e2ca78e..00000000 --- a/identity/src/main/res/layout/fragment_scan_capture.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_selfie.xml b/identity/src/main/res/layout/fragment_selfie.xml deleted file mode 100644 index 16b81407..00000000 --- a/identity/src/main/res/layout/fragment_selfie.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_support.xml b/identity/src/main/res/layout/fragment_support.xml deleted file mode 100644 index 5a998d46..00000000 --- a/identity/src/main/res/layout/fragment_support.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_tax_pin_verification.xml b/identity/src/main/res/layout/fragment_tax_pin_verification.xml deleted file mode 100644 index e140d957..00000000 --- a/identity/src/main/res/layout/fragment_tax_pin_verification.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_upload_capture.xml b/identity/src/main/res/layout/fragment_upload_capture.xml deleted file mode 100644 index 729a02a8..00000000 --- a/identity/src/main/res/layout/fragment_upload_capture.xml +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/fragment_welcome.xml b/identity/src/main/res/layout/fragment_welcome.xml deleted file mode 100644 index bceb1ffd..00000000 --- a/identity/src/main/res/layout/fragment_welcome.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/layout/list_item_countries.xml b/identity/src/main/res/layout/list_item_countries.xml deleted file mode 100644 index bfce2383..00000000 --- a/identity/src/main/res/layout/list_item_countries.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/navigation/identity_verification_nav_graph.xml b/identity/src/main/res/navigation/identity_verification_nav_graph.xml deleted file mode 100644 index f5c1c303..00000000 --- a/identity/src/main/res/navigation/identity_verification_nav_graph.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/identity/src/main/res/values/attrs.xml b/identity/src/main/res/values/attrs.xml index 57309950..54b3cb81 100644 --- a/identity/src/main/res/values/attrs.xml +++ b/identity/src/main/res/values/attrs.xml @@ -3,4 +3,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/identity/src/main/res/values/strings.xml b/identity/src/main/res/values/strings.xml index 714d8071..9c2bfdc8 100644 --- a/identity/src/main/res/values/strings.xml +++ b/identity/src/main/res/values/strings.xml @@ -3,15 +3,18 @@ Continue Complete Select + Select Front + Select Back Take Photo Scan Verify Take Again Scan Again - Resolve Error + Resolve Cancel Try Again Change Method + App Settings Identity verification Sandbox/Test Help And Support @@ -22,10 +25,10 @@ Something went wrong! - The connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond. Retry later. + The connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond. Retry later Missing Verification Data! + An error occurred when processing your verification data. Please try again! An unexpected error occurred. Please try again later. - The following unexpected errors occurred:\n\n%1$s.\n\nPlease try again later! Verification Attempts Depleted You have depleted your allocated number of verification attempts. Please contact support to start a new verification! Could not scan document! @@ -38,7 +41,7 @@ Accept Decline %1$s partners with Falu for secure identity verification. - Falu identity was built with the global privacy regulations in mind.\nData security & privacy are important to us. You are about to begin the identity verification process.\nData collected will be handled according to the Falu Privacy Policy.\n\nKindly have your identification document close by and get started with the process! + Falu identity was built with the global privacy regulations in mind.\nData security & privacy are important to us. You are about to begin the identity verification process.\nData collected will be handled according to the Falu Privacy Policy.\nKindly have your identification document close by and get started with the process! Select issuing country to which see which identity documents are acceptable. @@ -56,7 +59,7 @@ %1$s upload - Front of %1$s + Front of %1$s Back of %1$s @@ -79,7 +82,7 @@ Scan your face! Position your face in the center. - Face detected, stay still scan in progress! + Face detected, scan in progress. Stay still! Scan Complete! @@ -95,6 +98,8 @@ Invalid document number. The first name is required. The last name is required. + The gender is required. + The date of birth is required. Kindly provide your tax PIN number diff --git a/identity/src/test/java/io/falu/identity/IdentityVerificationActivityTest.kt b/identity/src/test/java/io/falu/identity/IdentityVerificationActivityTest.kt deleted file mode 100644 index 2bd6bb42..00000000 --- a/identity/src/test/java/io/falu/identity/IdentityVerificationActivityTest.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.falu.identity - -class IdentityVerificationActivityTest \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/TestApplication.kt b/identity/src/test/java/io/falu/identity/TestApplication.kt new file mode 100644 index 00000000..a1fbecaa --- /dev/null +++ b/identity/src/test/java/io/falu/identity/TestApplication.kt @@ -0,0 +1,11 @@ +package io.falu.identity + +import android.app.Application +import com.google.android.material.R as MaterialR + +internal class TestApplication : Application() { + override fun onCreate() { + super.onCreate() + setTheme(MaterialR.style.Theme_MaterialComponents_DayNight_NoActionBar) + } +} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/api/IdentityVerificationApiClientTests.kt b/identity/src/test/java/io/falu/identity/api/IdentityVerificationApiClientTests.kt index bb5a2a52..34720cc4 100644 --- a/identity/src/test/java/io/falu/identity/api/IdentityVerificationApiClientTests.kt +++ b/identity/src/test/java/io/falu/identity/api/IdentityVerificationApiClientTests.kt @@ -18,9 +18,9 @@ import io.falu.identity.api.models.verification.VerificationDocumentUpload import io.falu.identity.api.models.verification.VerificationModel import io.falu.identity.api.models.verification.VerificationOptions import io.falu.identity.api.models.verification.VerificationOptionsForDocument -import io.falu.identity.api.models.verification.VerificationUpdateOptions import io.falu.identity.api.models.verification.VerificationStatus import io.falu.identity.api.models.verification.VerificationType +import io.falu.identity.api.models.verification.VerificationUpdateOptions import io.falu.identity.api.models.verification.VerificationUploadRequest import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -55,7 +55,8 @@ class IdentityVerificationApiClientTests { type = VerificationType.IDENTITY_NUMBER, status = VerificationStatus.INPUT_REQUIRED, options = VerificationOptions( - countries = mutableListOf("ken"), document = VerificationOptionsForDocument( + countries = mutableListOf("ken"), + document = VerificationOptionsForDocument( mutableListOf(IdentityDocumentType.IDENTITY_CARD, IdentityDocumentType.PASSPORT) ) ), diff --git a/identity/src/test/java/io/falu/identity/capture/UploadCaptureFragmentTest.kt b/identity/src/test/java/io/falu/identity/capture/UploadCaptureFragmentTest.kt deleted file mode 100644 index 4beaf7db..00000000 --- a/identity/src/test/java/io/falu/identity/capture/UploadCaptureFragmentTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -package io.falu.identity.capture - -import android.net.Uri -import android.os.Build -import android.view.View -import androidx.core.os.bundleOf -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.lifecycle.MutableLiveData -import androidx.navigation.Navigation -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import com.nhaarman.mockitokotlin2.KArgumentCaptor -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.same -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever -import io.falu.identity.ContractArgs -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder -import io.falu.identity.api.DocumentUploadDisposition -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.capture.scan.DocumentScanViewModel -import io.falu.identity.capture.upload.UploadCaptureFragment -import io.falu.identity.databinding.FragmentUploadCaptureBinding -import io.falu.identity.documents.DocumentSelectionFragment -import io.falu.identity.utils.createFactoryFor -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.doReturn -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File -import kotlin.test.assertEquals -import com.google.android.material.R as MatR - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) -class UploadCaptureFragmentTest { - - private val uri = mock() - - private val documentUploadDisposition = MutableLiveData(DocumentUploadDisposition()) - - private val mockCaptureDocumentViewModel = mock {} - private val mockDocumentScanViewModel = mock {} - private val modelFile = MutableLiveData() - - private val mockIdentityVerificationViewModel = mock { - whenever(it.documentUploadDisposition).thenReturn(documentUploadDisposition) - on { it.documentDetectorModelFile } doReturn (modelFile) - on { analyticsRequestBuilder }.thenReturn( - IdentityAnalyticsRequestBuilder( - context = ApplicationProvider.getApplicationContext(), - args = contractArgs - ) - ) - } - - @Test - fun `test if result callbacks are initialized and UI correctness for ID cards and DLs`() { - launchUploadFragment { binding, _, fragment -> - val callbackCaptor = argumentCaptor<(Uri) -> Unit>() - - verify(mockCaptureDocumentViewModel).pickDocumentImages( - same(fragment), - callbackCaptor.capture(), - callbackCaptor.capture() - ) - - assertEquals(binding.cardDocumentBack.visibility, View.VISIBLE) - assertEquals(binding.progressSelectFront.visibility, View.GONE) - assertEquals(binding.progressSelectBack.visibility, View.GONE) - assertEquals(binding.buttonContinue.isEnabled, false) - } - } - - @Test - fun `test if result callbacks are initialized and UI correctness for passports`() { - launchUploadFragment(IdentityDocumentType.PASSPORT) { binding, _, fragment -> - val callbackCaptor: KArgumentCaptor<(Uri) -> Unit> = argumentCaptor() - - verify(mockCaptureDocumentViewModel).pickDocumentImages( - same(fragment), - callbackCaptor.capture(), - callbackCaptor.capture() - ) - - assertEquals(binding.cardDocumentBack.visibility, View.GONE) - assertEquals(binding.progressSelectFront.visibility, View.GONE) - assertEquals(binding.progressSelectBack.visibility, View.GONE) - assertEquals(binding.buttonContinue.isEnabled, false) - } - } - - @Test - fun `test if image pick works for id and dl`() { - pickImages() - } - - @Test - fun `test if image pick works for passport`() { - pickImages(IdentityDocumentType.PASSPORT) - } - - private fun pickImages(documentType: IdentityDocumentType = IdentityDocumentType.IDENTITY_CARD) { - launchUploadFragment { binding, _, fragment -> - val frontImageCaptor: KArgumentCaptor<(Uri) -> Unit> = argumentCaptor() - val backImageImageCaptor: KArgumentCaptor<(Uri) -> Unit> = argumentCaptor() - - verify(mockCaptureDocumentViewModel).pickDocumentImages( - same(fragment), - frontImageCaptor.capture(), - backImageImageCaptor.capture() - ) - - binding.buttonSelectFront.callOnClick() - - verify(mockCaptureDocumentViewModel).pickImageFront() - frontImageCaptor.lastValue(uri) - - binding.buttonSelectBack.callOnClick() - - if (documentType != IdentityDocumentType.PASSPORT) { - verify(mockCaptureDocumentViewModel).pickImageFront() - backImageImageCaptor.lastValue(uri) - } - } - } - - private fun launchUploadFragment( - documentType: IdentityDocumentType = IdentityDocumentType.IDENTITY_CARD, - block: ( - binding: FragmentUploadCaptureBinding, - navController: TestNavHostController, - fragment: AbstractCaptureFragment - ) -> Unit - ) { - launchFragmentInContainer( - bundleOf(DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE to documentType), - themeResId = MatR.style.Theme_MaterialComponents - ) { - UploadCaptureFragment(createFactoryFor(mockIdentityVerificationViewModel)).also { - it.captureDocumentViewModelFactory = createFactoryFor(mockCaptureDocumentViewModel) - it.documentScanViewModelFactory = createFactoryFor(mockDocumentScanViewModel) - } - }.onFragment { - val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) - - navController.setGraph(R.navigation.identity_verification_nav_graph) - - navController.setCurrentDestination(R.id.fragment_upload_capture) - - Navigation.setViewNavController(it.requireView(), navController) - - block(FragmentUploadCaptureBinding.bind(it.requireView()), navController, it) - } - } - - private companion object { - const val temporaryKey = "fskt_1234" - val logo = org.mockito.kotlin.mock() - - val contractArgs = ContractArgs( - temporaryKey = temporaryKey, - verificationId = "iv_1234", - maxNetworkRetries = 0, - workspaceLogo = logo - ) - } -} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/confirmation/ConfirmationFragmentTest.kt b/identity/src/test/java/io/falu/identity/confirmation/ConfirmationFragmentTest.kt deleted file mode 100644 index 2be21747..00000000 --- a/identity/src/test/java/io/falu/identity/confirmation/ConfirmationFragmentTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package io.falu.identity.confirmation - -import android.os.Build -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.navigation.Navigation -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import io.falu.identity.IdentityVerificationResult -import io.falu.identity.IdentityVerificationResultCallback -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.databinding.FragmentConfirmationBinding -import io.falu.identity.utils.createFactoryFor -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import com.google.android.material.R as MatR - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) -class ConfirmationFragmentTest { - private val mockVerificationResultCallback = mock() - private val identityVerificationViewModel = mock() - - @Test - fun testButtonClickFinishesWithComplete() { - launchConfirmationFragment { binding, _ -> - binding.buttonFinish.callOnClick() - - verify(mockVerificationResultCallback).onFinishWithResult( - eq(IdentityVerificationResult.Succeeded) - ) - } - } - - private fun launchConfirmationFragment( - block: (binding: FragmentConfirmationBinding, navController: TestNavHostController) -> Unit - ) = - launchFragmentInContainer(themeResId = MatR.style.Theme_MaterialComponents) { - ConfirmationFragment( - createFactoryFor(identityVerificationViewModel), - mockVerificationResultCallback - ) - }.onFragment { - - val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) - - navController.setGraph(R.navigation.identity_verification_nav_graph) - - navController.setCurrentDestination(R.id.fragment_welcome) - - Navigation.setViewNavController(it.requireView(), navController) - - block(FragmentConfirmationBinding.bind(it.requireView()), navController) - } -} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/documents/DocumentSelectionFragmentTest.kt b/identity/src/test/java/io/falu/identity/documents/DocumentSelectionFragmentTest.kt deleted file mode 100644 index 8fec0a27..00000000 --- a/identity/src/test/java/io/falu/identity/documents/DocumentSelectionFragmentTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package io.falu.identity.documents - -import android.net.Uri -import android.os.Build -import android.view.View -import android.widget.ProgressBar -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.navigation.Navigation -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import com.google.android.material.button.MaterialButton -import io.falu.identity.ContractArgs -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.country.Country -import io.falu.identity.api.models.country.SupportedCountry -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.api.models.verification.VerificationOptions -import io.falu.identity.api.models.verification.VerificationOptionsForDocument -import io.falu.identity.api.models.verification.VerificationType -import io.falu.identity.databinding.FragmentDocumentSelectionBinding -import io.falu.identity.utils.createFactoryFor -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.KArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.assertEquals -import com.google.android.material.R as MatR - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) -class DocumentSelectionFragmentTest { - - private val mockIdentityVerificationViewModel = mock { - on { analyticsRequestBuilder }.thenReturn( - IdentityAnalyticsRequestBuilder( - context = ApplicationProvider.getApplicationContext(), - args = contractArgs - ) - ) - } - - private val supportedCountries = arrayOf( - SupportedCountry( - country = Country("ken", "Kenya", flag = "http://cake.com/flag.svg"), - documents = mutableListOf( - IdentityDocumentType.IDENTITY_CARD, - IdentityDocumentType.DRIVING_LICENSE, - IdentityDocumentType.PASSPORT - ) - ) - ) - - private val verificationWithAllowedDocuments = mock().also { - whenever(it.type).thenReturn(VerificationType.DOCUMENT) - whenever(it.options).thenReturn( - VerificationOptions( - countries = mutableListOf("ken", "tza", "uga"), - document = VerificationOptionsForDocument( - allowed = mutableListOf( - IdentityDocumentType.PASSPORT, - IdentityDocumentType.IDENTITY_CARD, - IdentityDocumentType.DRIVING_LICENSE - ) - ) - ) - ) - } - - private fun successfulVerification(data: Verification = verificationWithAllowedDocuments) { - val successCaptor: KArgumentCaptor<(Verification) -> Unit> = argumentCaptor() - verify(mockIdentityVerificationViewModel, times(1)).observeForVerificationResults( - any(), - successCaptor.capture(), - any() - ) - successCaptor.lastValue(data) - } - - private fun getSupportedCountries() { - val successCaptor: KArgumentCaptor<(Array) -> Unit> = argumentCaptor() - verify(mockIdentityVerificationViewModel) - .observerForSupportedCountriesResults(any(), successCaptor.capture(), any()) - - successCaptor.lastValue(supportedCountries) - } - - @Test - fun `test setup of supported countries and available documents`() { - launchDocumentSelectionFragment { binding, _ -> - getSupportedCountries() - - successfulVerification() - - assertEquals(binding.inputIssuingCountry.text.toString(), "Kenya") - assertEquals(binding.chipDrivingLicense.isEnabled, true) - assertEquals(binding.chipIdentityCard.isEnabled, true) - assertEquals(binding.chipPassport.isEnabled, true) - } - } - - @Test - fun `test if identity card document selected and continue`() { - launchDocumentSelectionFragment { binding, _ -> - getSupportedCountries() - - successfulVerification() - - binding.chipIdentityCard.isChecked = true - - binding.buttonContinue.findViewById(R.id.button_loading).callOnClick() - - verify(mockIdentityVerificationViewModel).updateVerification(any(), any(), any(), any()) - - assertEquals( - binding.buttonContinue.findViewById(R.id.button_loading).isEnabled, - false - ) - assertEquals( - binding.buttonContinue.findViewById(R.id.progress_view).visibility, - View.VISIBLE - ) - } - } - - private fun launchDocumentSelectionFragment( - block: (binding: FragmentDocumentSelectionBinding, navController: TestNavHostController) -> Unit - ) { - launchFragmentInContainer(themeResId = MatR.style.Theme_MaterialComponents) { - DocumentSelectionFragment(createFactoryFor(mockIdentityVerificationViewModel)) - }.onFragment { - val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) - - navController.setGraph(R.navigation.identity_verification_nav_graph) - - navController.setCurrentDestination(R.id.fragment_document_selection) - - Navigation.setViewNavController(it.requireView(), navController) - - block(FragmentDocumentSelectionBinding.bind(it.requireView()), navController) - } - } - - private companion object { - const val temporaryKey = "fskt_1234" - val logo = mock() - - val contractArgs = ContractArgs( - temporaryKey = temporaryKey, - verificationId = "iv_1234", - maxNetworkRetries = 0, - workspaceLogo = logo - ) - } -} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/documents/DocumentsCaptureMethodsFragmentTest.kt b/identity/src/test/java/io/falu/identity/documents/DocumentsCaptureMethodsFragmentTest.kt deleted file mode 100644 index b94836f3..00000000 --- a/identity/src/test/java/io/falu/identity/documents/DocumentsCaptureMethodsFragmentTest.kt +++ /dev/null @@ -1,120 +0,0 @@ -package io.falu.identity.documents - -import android.net.Uri -import android.os.Build -import android.view.View -import androidx.core.os.bundleOf -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.navigation.Navigation -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import io.falu.identity.ContractArgs -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.api.models.verification.VerificationOptions -import io.falu.identity.api.models.verification.VerificationOptionsForDocument -import io.falu.identity.databinding.FragmentDocumentCaptureMethodsBinding -import io.falu.identity.utils.createFactoryFor -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.KArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.assertEquals -import com.google.android.material.R as MatR - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) -class DocumentsCaptureMethodsFragmentTest { - - private val mockIdentityVerificationViewModel = mock { - on { analyticsRequestBuilder }.thenReturn( - IdentityAnalyticsRequestBuilder( - context = ApplicationProvider.getApplicationContext(), - args = contractArgs - ) - ) - } - - private val verificationAllowUploads = mock().also { - whenever(it.options).thenReturn( - VerificationOptions( - allowUploads = true, - countries = mutableListOf("ken", "tza", "uga"), - document = VerificationOptionsForDocument( - - allowed = mutableListOf( - IdentityDocumentType.PASSPORT, - IdentityDocumentType.IDENTITY_CARD, - IdentityDocumentType.DRIVING_LICENSE - ) - ) - ) - ) - } - - private fun successfulVerification(data: Verification = verificationAllowUploads) { - val successCaptor: KArgumentCaptor<(Verification) -> Unit> = argumentCaptor() - verify(mockIdentityVerificationViewModel, times(1)).observeForVerificationResults( - any(), - successCaptor.capture(), - any() - ) - successCaptor.lastValue(data) - } - - @Test - fun `test when verification allows for uploads`() { - launchFragment { binding, _ -> - successfulVerification() - assertEquals(binding.viewCaptureMethodUpload.visibility, View.VISIBLE) - } - } - - private fun launchFragment( - block: ( - binding: FragmentDocumentCaptureMethodsBinding, - navController: TestNavHostController - ) -> Unit - ) { - launchFragmentInContainer( - fragmentArgs = bundleOf( - DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE to IdentityDocumentType.IDENTITY_CARD - ), - themeResId = MatR.style.Theme_MaterialComponents - ) { - DocumentCaptureMethodsFragment(createFactoryFor(mockIdentityVerificationViewModel)) - }.onFragment { - val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) - - navController.setGraph(R.navigation.identity_verification_nav_graph) - - navController.setCurrentDestination(R.id.fragment_document_capture_methods) - - Navigation.setViewNavController(it.requireView(), navController) - - block(FragmentDocumentCaptureMethodsBinding.bind(it.requireView()), navController) - } - } - - private companion object { - const val temporaryKey = "fskt_1234" - val logo = mock() - - val contractArgs = ContractArgs( - temporaryKey = temporaryKey, - verificationId = "iv_1234", - maxNetworkRetries = 0, - workspaceLogo = logo - ) - } -} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/documents/ManualCaptureFragmentTests.kt b/identity/src/test/java/io/falu/identity/documents/ManualCaptureFragmentTests.kt deleted file mode 100644 index 8669fa55..00000000 --- a/identity/src/test/java/io/falu/identity/documents/ManualCaptureFragmentTests.kt +++ /dev/null @@ -1,179 +0,0 @@ -package io.falu.identity.documents - -import android.net.Uri -import android.os.Build -import android.view.View -import androidx.core.os.bundleOf -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.lifecycle.MutableLiveData -import androidx.navigation.Navigation -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import io.falu.identity.ContractArgs -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder -import io.falu.identity.api.DocumentUploadDisposition -import io.falu.identity.api.models.IdentityDocumentType -import io.falu.identity.capture.AbstractCaptureFragment -import io.falu.identity.capture.CaptureDocumentViewModel -import io.falu.identity.capture.manual.ManualCaptureFragment -import io.falu.identity.capture.scan.DocumentScanViewModel -import io.falu.identity.databinding.FragmentManualCaptureBinding -import io.falu.identity.utils.createFactoryFor -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.KArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.same -import org.mockito.kotlin.verify -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File -import kotlin.test.assertEquals -import com.google.android.material.R as MatR - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) -class ManualCaptureFragmentTests { - private val uri = mock() - - private val documentUploadDisposition = MutableLiveData(DocumentUploadDisposition()) - - private val mockCaptureDocumentViewModel = mock {} - private val mockDocumentScanViewModel = mock {} - private val modelFile = MutableLiveData() - - private val mockIdentityVerificationViewModel = - com.nhaarman.mockitokotlin2.mock { - com.nhaarman.mockitokotlin2.whenever(it.documentUploadDisposition) - .thenReturn(documentUploadDisposition) - on { it.documentDetectorModelFile } doReturn (modelFile) - on { analyticsRequestBuilder }.thenReturn( - IdentityAnalyticsRequestBuilder( - context = ApplicationProvider.getApplicationContext(), - args = contractArgs - ) - ) - } - - @Test - fun `test if result callbacks are initialized and UI correctness for ID cards and DLs`() { - launchManualFragment { binding, _, fragment -> - val callbackCaptor: KArgumentCaptor<(Uri) -> Unit> = argumentCaptor() - - verify(mockCaptureDocumentViewModel).captureDocumentImages( - same(fragment), - any(), - callbackCaptor.capture(), - callbackCaptor.capture() - ) - - assertEquals(binding.cardDocumentBack.visibility, View.VISIBLE) - assertEquals(binding.progressSelectFront.visibility, View.GONE) - assertEquals(binding.progressSelectBack.visibility, View.GONE) - assertEquals(binding.buttonContinue.isEnabled, false) - } - } - - @Test - fun `test if result callbacks are initialized and UI correctness for passports`() { - launchManualFragment(IdentityDocumentType.PASSPORT) { binding, _, fragment -> - val callbackCaptor: KArgumentCaptor<(Uri) -> Unit> = argumentCaptor() - - verify(mockCaptureDocumentViewModel).captureDocumentImages( - same(fragment), - any(), - callbackCaptor.capture(), - callbackCaptor.capture() - ) - - assertEquals(binding.cardDocumentBack.visibility, View.GONE) - assertEquals(binding.progressSelectFront.visibility, View.GONE) - assertEquals(binding.progressSelectBack.visibility, View.GONE) - assertEquals(binding.buttonContinue.isEnabled, false) - } - } - - @Test - fun `test if image capture works for id and dl`() { - captureImage() - } - - @Test - fun `test if image capture works for passport`() { - captureImage(IdentityDocumentType.PASSPORT) - } - - private fun captureImage( - documentType: IdentityDocumentType = IdentityDocumentType.IDENTITY_CARD - ) { - launchManualFragment(documentType) { binding, _, fragment -> - val frontImageCaptor: KArgumentCaptor<(Uri) -> Unit> = argumentCaptor() - val backImageImageCaptor: KArgumentCaptor<(Uri) -> Unit> = argumentCaptor() - - verify(mockCaptureDocumentViewModel).captureDocumentImages( - same(fragment), - any(), - frontImageCaptor.capture(), - backImageImageCaptor.capture() - ) - - binding.buttonSelectFront.callOnClick() - - verify(mockCaptureDocumentViewModel).captureImageFront(fragment.requireContext()) - frontImageCaptor.lastValue(uri) - - binding.buttonSelectBack.callOnClick() - - if (documentType != IdentityDocumentType.PASSPORT) { - verify(mockCaptureDocumentViewModel).captureImageBack(fragment.requireContext()) - backImageImageCaptor.lastValue(uri) - } - } - } - - private fun launchManualFragment( - documentType: IdentityDocumentType = IdentityDocumentType.IDENTITY_CARD, - block: ( - binding: FragmentManualCaptureBinding, - navController: TestNavHostController, - fragment: AbstractCaptureFragment - ) -> Unit - ) { - launchFragmentInContainer( - bundleOf(DocumentSelectionFragment.KEY_IDENTITY_DOCUMENT_TYPE to documentType), - themeResId = MatR.style.Theme_MaterialComponents - ) { - ManualCaptureFragment(createFactoryFor(mockIdentityVerificationViewModel)).also { - it.captureDocumentViewModelFactory = createFactoryFor(mockCaptureDocumentViewModel) - it.documentScanViewModelFactory = createFactoryFor(mockDocumentScanViewModel) - } - }.onFragment { - val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) - - navController.setGraph(R.navigation.identity_verification_nav_graph) - - navController.setCurrentDestination(R.id.fragment_manual_capture) - - Navigation.setViewNavController(it.requireView(), navController) - - block(FragmentManualCaptureBinding.bind(it.requireView()), navController, it) - } - } - - private companion object { - const val temporaryKey = "fskt_1234" - val logo = mock() - - val contractArgs = ContractArgs( - temporaryKey = temporaryKey, - verificationId = "iv_1234", - maxNetworkRetries = 0, - workspaceLogo = logo - ) - } -} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/screens/ConfirmationScreenTest.kt b/identity/src/test/java/io/falu/identity/screens/ConfirmationScreenTest.kt new file mode 100644 index 00000000..3ee6bede --- /dev/null +++ b/identity/src/test/java/io/falu/identity/screens/ConfirmationScreenTest.kt @@ -0,0 +1,108 @@ +package io.falu.identity.screens + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.MutableLiveData +import androidx.test.core.app.ApplicationProvider +import io.falu.identity.ContractArgs +import io.falu.identity.IdentityVerificationResult +import io.falu.identity.IdentityVerificationResultCallback +import io.falu.identity.R +import io.falu.identity.TestApplication +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.api.models.verification.VerificationOptions +import io.falu.identity.api.models.verification.VerificationOptionsForDocument +import io.falu.identity.viewModel.IdentityVerificationViewModel +import okhttp3.Headers +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import software.tingle.api.ResourceResponse + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApplication::class, sdk = [Build.VERSION_CODES.Q]) +internal class ConfirmationScreenTest { + private val context = ApplicationProvider.getApplicationContext() + private val mockVerificationResultCallback = mock() + private val verificationResponse = MutableLiveData?>(null) + + @get:Rule + val composeTestRule = createComposeRule() + + private val mockIdentityVerificationViewModel = mock { + on { analyticsRequestBuilder }.thenReturn( + IdentityAnalyticsRequestBuilder( + context = ApplicationProvider.getApplicationContext(), + args = contractArgs + ) + ) + on { verification }.thenReturn(verificationResponse) + } + + private val verification = mock().also { + whenever(it.options).thenReturn( + VerificationOptions( + allowUploads = true, + countries = mutableListOf("ken", "tza", "uga"), + document = VerificationOptionsForDocument( + + allowed = mutableListOf( + IdentityDocumentType.PASSPORT, + IdentityDocumentType.IDENTITY_CARD, + IdentityDocumentType.DRIVING_LICENSE + ) + ) + ) + ) + } + + @Test + fun `test Button Click Finishes With Complete`() { + setComposeTestRuleWith { + onNodeWithText(context.getString(R.string.button_finish)).performClick() + + verify(mockVerificationResultCallback).onFinishWithResult(eq(IdentityVerificationResult.Succeeded)) + } + } + + private fun setComposeTestRuleWith(testBlock: ComposeContentTestRule.() -> Unit = {}) { + val response = ResourceResponse( + 200, + Headers.headersOf(), + verification, + null + ) + verificationResponse.postValue(response) + + composeTestRule.setContent { + ConfirmationScreen(mockIdentityVerificationViewModel, mockVerificationResultCallback) + } + + with(composeTestRule, testBlock) + } + + private companion object { + const val temporaryKey = "fskt_1234" + val logo = mock() + + val contractArgs = ContractArgs( + temporaryKey = temporaryKey, + verificationId = "iv_1234", + maxNetworkRetries = 0, + workspaceLogo = logo + ) + } +} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/screens/DocumentCaptureMethodScreenTest.kt b/identity/src/test/java/io/falu/identity/screens/DocumentCaptureMethodScreenTest.kt new file mode 100644 index 00000000..b80b3d87 --- /dev/null +++ b/identity/src/test/java/io/falu/identity/screens/DocumentCaptureMethodScreenTest.kt @@ -0,0 +1,115 @@ +package io.falu.identity.screens + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.MutableLiveData +import androidx.test.core.app.ApplicationProvider +import io.falu.identity.ContractArgs +import io.falu.identity.R +import io.falu.identity.TestApplication +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.UploadMethod +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.api.models.verification.VerificationOptions +import io.falu.identity.api.models.verification.VerificationOptionsForDocument +import io.falu.identity.viewModel.IdentityVerificationViewModel +import okhttp3.Headers +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import software.tingle.api.ResourceResponse + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApplication::class, sdk = [Build.VERSION_CODES.Q]) +internal class DocumentCaptureMethodScreenTest { + private val context = ApplicationProvider.getApplicationContext() + + @get:Rule + val composeTestRule = createComposeRule() + + private val verificationResponse = MutableLiveData?>(null) + private val mockNavigateToCaptureMethod: (UploadMethod) -> Unit = mock() + + private val mockIdentityVerificationViewModel = mock { + on { analyticsRequestBuilder }.thenReturn( + IdentityAnalyticsRequestBuilder( + context = ApplicationProvider.getApplicationContext(), + args = contractArgs + ) + ) + on { verification }.thenReturn(verificationResponse) + } + + private val verificationAllowUploads = mock().also { + whenever(it.options).thenReturn( + VerificationOptions( + allowUploads = true, + countries = mutableListOf("ken", "tza", "uga"), + document = VerificationOptionsForDocument( + + allowed = mutableListOf( + IdentityDocumentType.PASSPORT, + IdentityDocumentType.IDENTITY_CARD, + IdentityDocumentType.DRIVING_LICENSE + ) + ) + ) + ) + } + + @Test + fun `test capture method card navigation`() { + setComposeTestRuleWith { + onNodeWithText(context.getString(R.string.document_capture_method_scan)).assertExists().performClick() + verify(mockNavigateToCaptureMethod).invoke(UploadMethod.AUTO) + + onNodeWithText(context.getString(R.string.document_capture_method_photo)).assertExists().performClick() + verify(mockNavigateToCaptureMethod).invoke(UploadMethod.MANUAL) + + onNodeWithText(context.getString(R.string.document_capture_method_upload)).assertExists().performClick() + verify(mockNavigateToCaptureMethod).invoke(UploadMethod.UPLOAD) + } + } + + private fun setComposeTestRuleWith( + documentType: IdentityDocumentType = IdentityDocumentType.PASSPORT, + testBlock: ComposeContentTestRule.() -> Unit = {} + ) { + val response = ResourceResponse( + 200, + Headers.headersOf(), + verificationAllowUploads, + null + ) + verificationResponse.postValue(response) + + composeTestRule.setContent { + DocumentCaptureMethodsScreen(mockIdentityVerificationViewModel, documentType, mockNavigateToCaptureMethod) + } + + with(composeTestRule, testBlock) + } + + private companion object { + const val temporaryKey = "fskt_1234" + val logo = mock() + + val contractArgs = ContractArgs( + temporaryKey = temporaryKey, + verificationId = "iv_1234", + maxNetworkRetries = 0, + workspaceLogo = logo + ) + } +} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/screens/DocumentSelectionScreenTest.kt b/identity/src/test/java/io/falu/identity/screens/DocumentSelectionScreenTest.kt new file mode 100644 index 00000000..1b04356c --- /dev/null +++ b/identity/src/test/java/io/falu/identity/screens/DocumentSelectionScreenTest.kt @@ -0,0 +1,153 @@ +package io.falu.identity.screens + +import android.net.Uri +import android.os.Build +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.lifecycle.MutableLiveData +import androidx.test.core.app.ApplicationProvider +import io.falu.identity.ContractArgs +import io.falu.identity.TestApplication +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.country.Country +import io.falu.identity.api.models.country.SupportedCountry +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.api.models.verification.VerificationOptions +import io.falu.identity.api.models.verification.VerificationOptionsForDocument +import io.falu.identity.api.models.verification.VerificationType +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.ui.TAG_INPUT_ISSUING_COUNTRY +import io.falu.identity.viewModel.IdentityVerificationViewModel +import okhttp3.Headers +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import software.tingle.api.ResourceResponse + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApplication::class, sdk = [Build.VERSION_CODES.Q]) +internal class DocumentSelectionScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val verificationResponse = MutableLiveData?>(null) + private val supportedCountriesResponse = MutableLiveData>?>() + + private val navActions = mock { + on { navigateToDocumentCaptureMethods(any()) }.then {} + on { navigateToErrorWithFailure(any()) }.then {} + } + + private val mockIdentityVerificationViewModel = mock { + on { analyticsRequestBuilder }.thenReturn( + IdentityAnalyticsRequestBuilder( + context = ApplicationProvider.getApplicationContext(), + args = contractArgs + ) + ) + on { verification }.thenReturn(verificationResponse) + on { supportedCountries }.thenReturn(supportedCountriesResponse) + } + + private val supportedCountries = arrayOf( + SupportedCountry( + country = Country("ken", "Kenya", flag = "http://cake.com/flag.svg"), + documents = mutableListOf( + IdentityDocumentType.IDENTITY_CARD, + IdentityDocumentType.DRIVING_LICENSE, + IdentityDocumentType.PASSPORT + ) + ) + ) + + private val verificationWithAllowedDocuments = mock().also { + whenever(it.type).thenReturn(VerificationType.DOCUMENT) + whenever(it.options).thenReturn( + VerificationOptions( + countries = mutableListOf("ken", "tza", "uga"), + document = VerificationOptionsForDocument( + allowed = mutableListOf( + IdentityDocumentType.PASSPORT, + IdentityDocumentType.IDENTITY_CARD, + IdentityDocumentType.DRIVING_LICENSE + ) + ) + ) + ) + } + + @Test + fun `test setup of supported countries and available documents`() { + val response = ResourceResponse( + 200, + Headers.headersOf(), + verificationWithAllowedDocuments, + null + ) + + setComposeTestRuleWith(response) { + onNodeWithTag(TAG_INPUT_ISSUING_COUNTRY).performClick() + + onNodeWithTag(TAG_DOCUMENT_ID_CARD).assertExists().assertIsNotEnabled() + onNodeWithTag(TAG_DOCUMENT_PASSPORT).assertExists().assertIsNotEnabled() + } + } + + @Test + fun `test if identity card document selected and continue`() { + val response = ResourceResponse( + 200, + Headers.headersOf(), + verificationWithAllowedDocuments, + null + ) + + setComposeTestRuleWith(response) { + composeTestRule.onNodeWithTag(TAG_DOCUMENT_ID_CARD).performClick() + + composeTestRule.onNodeWithTag(TAG_CONTINUE_BUTTON).performClick() + composeTestRule.onNodeWithTag(TAG_CONTINUE_BUTTON).assertIsNotEnabled() + } + } + + private fun setComposeTestRuleWith( + response: ResourceResponse, + testBlock: ComposeContentTestRule.() -> Unit = {} + ) { + verificationResponse.postValue(response) + supportedCountriesResponse.postValue( + ResourceResponse( + 200, + Headers.headersOf(), + supportedCountries, + null + ) + ) + composeTestRule.setContent { + DocumentSelectionScreen(mockIdentityVerificationViewModel, navActions) + } + + with(composeTestRule, testBlock) + } + + private companion object { + const val temporaryKey = "fskt_1234" + val logo = mock() + + val contractArgs = ContractArgs( + temporaryKey = temporaryKey, + verificationId = "iv_1234", + maxNetworkRetries = 0, + workspaceLogo = logo + ) + } +} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/screens/ManualCaptureScreenTest.kt b/identity/src/test/java/io/falu/identity/screens/ManualCaptureScreenTest.kt new file mode 100644 index 00000000..e8cdec57 --- /dev/null +++ b/identity/src/test/java/io/falu/identity/screens/ManualCaptureScreenTest.kt @@ -0,0 +1,186 @@ +package io.falu.identity.screens + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.MutableLiveData +import androidx.test.core.app.ApplicationProvider +import io.falu.identity.ContractArgs +import io.falu.identity.R +import io.falu.identity.TestApplication +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder +import io.falu.identity.api.DocumentUploadDisposition +import io.falu.identity.api.models.IdentityDocumentType +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.api.models.verification.VerificationType +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.screens.capture.ManualCaptureScreen +import io.falu.identity.utils.IdentityImageHandler +import io.falu.identity.viewModel.IdentityVerificationViewModel +import okhttp3.Headers +import org.junit.Rule +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import software.tingle.api.ResourceResponse +import java.io.File +import kotlin.test.Test + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApplication::class, sdk = [Build.VERSION_CODES.Q]) +internal class ManualCaptureScreenTest { + + private val context = ApplicationProvider.getApplicationContext() + + private val documentUploadDisposition = MutableLiveData(DocumentUploadDisposition()) + private val modelFile = MutableLiveData() + + private val verificationResponse = MutableLiveData?>(null) + private val mockImageHandler = mock() + + private val mockIdentityVerificationViewModel = mock { + whenever(it.documentUploadDisposition).doReturn(documentUploadDisposition) + on { it.documentDetectorModelFile } doReturn (modelFile) + on { analyticsRequestBuilder }.doReturn( + IdentityAnalyticsRequestBuilder( + context = ApplicationProvider.getApplicationContext(), + args = contractArgs + ) + ) + on { verification }.doReturn(verificationResponse) + on { imageHandler }.thenReturn(mockImageHandler) + } + + private val verification = mock().also { + whenever(it.type).thenReturn(VerificationType.DOCUMENT) + } + + private val navActions = mock { + on { navigateToDocumentCaptureMethods(any()) }.then {} + on { navigateToErrorWithFailure(any()) }.then {} + } + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `test if result callbacks are initialized and UI correctness for ID cards and DLs`() { + setComposeTestRuleWith(documentType = IdentityDocumentType.IDENTITY_CARD) { + val document = context.getString(IdentityDocumentType.IDENTITY_CARD.titleRes) + val title = context.getString(R.string.upload_document_capture_title, document) + + // Check if the title is displayed correctly + onNodeWithText(title).assertIsDisplayed() + + // Check that both front and back capture buttons are visible + onNodeWithText( + context.getString( + R.string.upload_document_capture_document_front, + document + ) + ).assertIsDisplayed() + onNodeWithText( + context.getString( + R.string.upload_document_capture_document_back, + document + ) + ).assertIsDisplayed() + } + } + + @Test + fun `test if result callbacks are initialized and UI correctness for passports`() { + setComposeTestRuleWith(documentType = IdentityDocumentType.PASSPORT) { + // Check if the title is displayed correctly for passport + val document = context.getString(IdentityDocumentType.PASSPORT.titleRes) + val title = context.getString(R.string.upload_document_capture_title, document) + + // Check if the title is displayed correctly + onNodeWithText(title).assertIsDisplayed() + + // Check that both front and back capture buttons are visible + onNodeWithText( + context.getString( + R.string.upload_document_capture_document_front, + document + ) + ).assertIsDisplayed() + onNodeWithText( + context.getString( + R.string.upload_document_capture_document_back, + document + ) + ).assertDoesNotExist() + } + } + + @Test + fun `test if image capture works for id and dl`() { + setComposeTestRuleWith(documentType = IdentityDocumentType.IDENTITY_CARD) { + val document = context.getString(IdentityDocumentType.IDENTITY_CARD.titleRes) + + // Simulate clicking on front capture button + onNodeWithText(context.getString(R.string.button_select_front, document)).performClick() + verify(mockIdentityVerificationViewModel.imageHandler).captureImageFront(any()) + + // Simulate clicking on back capture button + onNodeWithText(context.getString(R.string.button_select_back)).performClick() + verify(mockIdentityVerificationViewModel.imageHandler).captureImageBack(any()) + } + } + + @Test + fun `test if image capture works for passport`() { + setComposeTestRuleWith(documentType = IdentityDocumentType.PASSPORT) { + // Simulate clicking on front capture button for passport + onNodeWithText(context.getString(R.string.button_select_front)).performClick() + verify(mockIdentityVerificationViewModel.imageHandler).captureImageFront(any()) + + // Ensure back capture is not present + onNodeWithText(context.getString(R.string.button_select_back)).assertDoesNotExist() + } + } + + private fun setComposeTestRuleWith( + documentType: IdentityDocumentType, + testBlock: ComposeContentTestRule.() -> Unit = {} + ) { + val response = ResourceResponse( + 200, + Headers.headersOf(), + verification, + null + ) + verificationResponse.postValue(response) + + documentUploadDisposition.postValue(DocumentUploadDisposition()) + + composeTestRule.setContent { + ManualCaptureScreen(mockIdentityVerificationViewModel, navActions, documentType) + } + + with(composeTestRule, testBlock) + } + + private companion object { + const val temporaryKey = "fskt_1234" + val logo = mock() + + val contractArgs = ContractArgs( + temporaryKey = temporaryKey, + verificationId = "iv_1234", + maxNetworkRetries = 0, + workspaceLogo = logo + ) + } +} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/screens/SupportScreenTest.kt b/identity/src/test/java/io/falu/identity/screens/SupportScreenTest.kt new file mode 100644 index 00000000..1bdb363e --- /dev/null +++ b/identity/src/test/java/io/falu/identity/screens/SupportScreenTest.kt @@ -0,0 +1,95 @@ +package io.falu.identity.screens + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.MutableLiveData +import androidx.test.core.app.ApplicationProvider +import io.falu.identity.ContractArgs +import io.falu.identity.R +import io.falu.identity.TestApplication +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder +import io.falu.identity.api.models.Support +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.viewModel.IdentityVerificationViewModel +import okhttp3.Headers +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import software.tingle.api.ResourceResponse + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApplication::class, sdk = [Build.VERSION_CODES.Q]) +internal class SupportScreenTest { + private val context = ApplicationProvider.getApplicationContext() + + private val verificationResponse = MutableLiveData?>(null) + + private val mockIdentityVerificationViewModel = mock { + on { analyticsRequestBuilder }.thenReturn( + IdentityAnalyticsRequestBuilder( + context = ApplicationProvider.getApplicationContext(), + args = contractArgs + ) + ) + on { verification }.thenReturn(verificationResponse) + } + + private val verification = mock().also { + whenever(it.support).thenReturn( + Support( + email = "support@example.com", + phone = "+2547123456789", + url = "https://support.example.com" + ) + ) + } + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `test loading of support data and initiate calls and emails`() { + setComposeTestRuleWith { + onNodeWithText(context.getString(R.string.support_text_email)).assertExists().performClick() + onNodeWithText(context.getString(R.string.support_text_call)).assertExists().performClick() + onNodeWithText(verification.support?.url ?: "").assertExists() + } + } + + private fun setComposeTestRuleWith(testBlock: ComposeContentTestRule.() -> Unit = {}) { + val response = ResourceResponse( + 200, + Headers.headersOf(), + verification, + null + ) + verificationResponse.postValue(response) + + composeTestRule.setContent { + SupportScreen(mockIdentityVerificationViewModel) + } + + with(composeTestRule, testBlock) + } + + private companion object { + const val temporaryKey = "fskt_1234" + val logo = mock() + + val contractArgs = ContractArgs( + temporaryKey = temporaryKey, + verificationId = "iv_1234", + maxNetworkRetries = 0, + workspaceLogo = logo + ) + } +} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/screens/WelcomeScreenTest.kt b/identity/src/test/java/io/falu/identity/screens/WelcomeScreenTest.kt new file mode 100644 index 00000000..b866db02 --- /dev/null +++ b/identity/src/test/java/io/falu/identity/screens/WelcomeScreenTest.kt @@ -0,0 +1,128 @@ +package io.falu.identity.screens + +import android.net.Uri +import android.os.Build +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.lifecycle.MutableLiveData +import androidx.test.core.app.ApplicationProvider +import io.falu.identity.ContractArgs +import io.falu.identity.IdentityVerificationResultCallback +import io.falu.identity.TestApplication +import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder +import io.falu.identity.api.IdentityVerificationApiClient +import io.falu.identity.api.models.WorkspaceInfo +import io.falu.identity.api.models.requirements.Requirement +import io.falu.identity.api.models.requirements.RequirementType +import io.falu.identity.api.models.verification.Verification +import io.falu.identity.api.models.verification.VerificationUpdateOptions +import io.falu.identity.navigation.IdentityVerificationNavActions +import io.falu.identity.viewModel.IdentityVerificationViewModel +import okhttp3.Headers +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import software.tingle.api.ResourceResponse + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApplication::class, sdk = [Build.VERSION_CODES.Q]) +internal class WelcomeScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val verificationResponse = MutableLiveData?>(null) + private val mockVerificationResultCallback = mock() + private val navActions = mock { + on { navigateToDocumentSelection() }.then {} + on { navigateToErrorWithFailure(any()) }.then {} + } + + private val verification = mock().also { + whenever(it.workspace).thenReturn( + WorkspaceInfo("Test", "ken") + ) + whenever(it.requirements).thenReturn( + Requirement(pending = mutableListOf(RequirementType.CONSENT), errors = mutableListOf()) + ) + whenever(it.remainingAttempts).thenReturn(null) + } + + private val mockIdentityVerificationViewModel = mock { + on { contractArgs }.thenReturn(contractArgs) + + on { apiClient }.thenReturn( + IdentityVerificationApiClient( + context = ApplicationProvider.getApplicationContext(), + apiKey = temporaryKey, + maxNetworkRetries = 0, + enableLogging = true + ) + ) + + on { analyticsRequestBuilder }.thenReturn( + IdentityAnalyticsRequestBuilder( + context = ApplicationProvider.getApplicationContext(), + args = contractArgs + ) + ) + + on { verification }.thenReturn(verificationResponse) + } + + @Test + fun `test if consent agreed to and update verification data`() { + setComposeTestRuleWith { + onNodeWithTag(WELCOME_ACCEPT_BUTTON).performClick() + + verify(mockIdentityVerificationViewModel).updateVerification( + eq(VerificationUpdateOptions(consent = true)), + any(), + any(), + any() + ) + } + } + + private fun setComposeTestRuleWith( + testBlock: ComposeContentTestRule.() -> Unit = {} + ) { + val response = ResourceResponse( + 200, + Headers.headersOf(), + verification, + null + ) + verificationResponse.postValue(response) + + composeTestRule.setContent { + WelcomeScreen( + mockIdentityVerificationViewModel, + navActions, + mockVerificationResultCallback + ) + } + + with(composeTestRule, testBlock) + } + + private companion object { + const val temporaryKey = "fskt_1234" + val logo = mock() + + val contractArgs = ContractArgs( + temporaryKey = temporaryKey, + verificationId = "iv_1234", + maxNetworkRetries = 0, + workspaceLogo = logo + ) + } +} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/support/SupportFragmentTest.kt b/identity/src/test/java/io/falu/identity/support/SupportFragmentTest.kt deleted file mode 100644 index bb4e8b9e..00000000 --- a/identity/src/test/java/io/falu/identity/support/SupportFragmentTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package io.falu.identity.support - -import android.net.Uri -import android.os.Build -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.navigation.Navigation -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import io.falu.identity.ContractArgs -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder -import io.falu.identity.api.models.Support -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.databinding.FragmentSupportBinding -import io.falu.identity.utils.createFactoryFor -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.KArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.assertEquals -import com.google.android.material.R as MatR - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) -class SupportFragmentTest { - - private val mockIdentityVerificationViewModel = mock { - on { analyticsRequestBuilder }.thenReturn( - IdentityAnalyticsRequestBuilder( - context = ApplicationProvider.getApplicationContext(), - args = contractArgs - ) - ) - } - - private val verification = mock().also { - whenever(it.support).thenReturn( - Support( - email = "support@example.com", - phone = "+2547123456789", - url = "https://support.example.com" - ) - ) - } - - private fun successfulVerification(data: Verification = verification) { - val successCaptor: KArgumentCaptor<(Verification) -> Unit> = argumentCaptor() - verify(mockIdentityVerificationViewModel, times(1)).observeForVerificationResults( - any(), - successCaptor.capture(), - any() - ) - successCaptor.lastValue(data) - } - - @Test - fun `test loading of support data and initiate calls and emails`() { - launchSupportFragment { binding, _ -> - successfulVerification() - - val support = verification.support!! - - assertEquals(binding.tvSupportUrl.text, support.url) - - binding.viewSupportCall.callOnClick() - - binding.viewSupportEmail.callOnClick() - } - } - - private fun launchSupportFragment( - block: (binding: FragmentSupportBinding, navController: TestNavHostController) -> Unit - ) { - launchFragmentInContainer(themeResId = MatR.style.Theme_MaterialComponents) { - SupportFragment( - createFactoryFor(mockIdentityVerificationViewModel) - ) - }.onFragment { - val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) - - navController.setGraph(R.navigation.identity_verification_nav_graph) - - navController.setCurrentDestination(R.id.fragment_support) - - Navigation.setViewNavController(it.requireView(), navController) - - block(FragmentSupportBinding.bind(it.requireView()), navController) - } - } - - private companion object { - const val temporaryKey = "fskt_1234" - val logo = mock() - - val contractArgs = ContractArgs( - temporaryKey = temporaryKey, - verificationId = "iv_1234", - maxNetworkRetries = 0, - workspaceLogo = logo - ) - } -} \ No newline at end of file diff --git a/identity/src/test/java/io/falu/identity/welcome/WelcomeFragmentTest.kt b/identity/src/test/java/io/falu/identity/welcome/WelcomeFragmentTest.kt deleted file mode 100644 index 5b343d78..00000000 --- a/identity/src/test/java/io/falu/identity/welcome/WelcomeFragmentTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package io.falu.identity.welcome - -import android.content.Context -import android.net.Uri -import android.os.Build -import android.view.View -import android.widget.ProgressBar -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.navigation.Navigation -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import com.google.android.material.button.MaterialButton -import io.falu.identity.ContractArgs -import io.falu.identity.IdentityVerificationResultCallback -import io.falu.identity.IdentityVerificationViewModel -import io.falu.identity.R -import io.falu.identity.analytics.IdentityAnalyticsRequestBuilder -import io.falu.identity.api.IdentityVerificationApiClient -import io.falu.identity.api.models.WorkspaceInfo -import io.falu.identity.api.models.requirements.Requirement -import io.falu.identity.api.models.requirements.RequirementType -import io.falu.identity.api.models.verification.Verification -import io.falu.identity.databinding.FragmentWelcomeBinding -import io.falu.identity.utils.createFactoryFor -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.times -import org.mockito.Mockito.verify -import org.mockito.kotlin.KArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.assertEquals -import com.google.android.material.R as MatR - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) -class WelcomeFragmentTest { - private val mockVerificationResultCallback = mock() - - private val verification = mock().also { - whenever(it.workspace).thenReturn( - WorkspaceInfo("Test", "ken") - ) - whenever(it.requirements).thenReturn( - Requirement(pending = mutableListOf(RequirementType.CONSENT), errors = mutableListOf()) - ) - whenever(it.remainingAttempts).thenReturn(null) - } - - private val mockIdentityVerificationViewModel = mock { - on { contractArgs }.thenReturn(contractArgs) - - on { apiClient }.thenReturn( - IdentityVerificationApiClient( - context = ApplicationProvider.getApplicationContext(), - apiKey = temporaryKey, - maxNetworkRetries = 0, - enableLogging = true - ) - ) - - on { analyticsRequestBuilder }.thenReturn( - IdentityAnalyticsRequestBuilder( - context = ApplicationProvider.getApplicationContext(), - args = contractArgs - ) - ) - } - - private fun successfulVerification(data: Verification = verification) { - val successCaptor: KArgumentCaptor<(Verification) -> Unit> = argumentCaptor() - verify(mockIdentityVerificationViewModel, times(1)).observeForVerificationResults( - any(), - successCaptor.capture(), - any() - ) - successCaptor.lastValue(data) - } - - @Test - fun `test if verification data is loading`() { - launchWelcomeFragment { binding, _ -> - assertEquals(binding.progressView.visibility, View.VISIBLE) - assertEquals(binding.scrollView.visibility, View.GONE) - assertEquals(binding.viewButtons.visibility, View.GONE) - } - } - - @Test - fun `test if verification data is displayed correctly`() { - launchWelcomeFragment { binding, _ -> - successfulVerification() - - assertEquals(binding.progressView.visibility, View.GONE) - assertEquals(binding.scrollView.visibility, View.VISIBLE) - assertEquals(binding.viewButtons.visibility, View.VISIBLE) - - assertEquals( - binding.tvWelcomeSubtitle.text, - (ApplicationProvider.getApplicationContext() as Context).getString( - R.string.welcome_subtitle, - verification.workspace.name - ) - ) - } - } - - @Test - fun `test if consent agreed to and update verification data`() { - launchWelcomeFragment { binding, _ -> - binding.buttonAccept.findViewById(R.id.button_loading).callOnClick() - - verify(mockIdentityVerificationViewModel).updateVerification(any(), any(), any(), any()) - - assertEquals( - binding.buttonAccept.findViewById(R.id.button_loading).isEnabled, - false - ) - assertEquals( - binding.buttonAccept.findViewById(R.id.progress_view).visibility, - View.VISIBLE - ) - } - } - - private fun launchWelcomeFragment( - block: (binding: FragmentWelcomeBinding, navController: TestNavHostController) -> Unit - ) { - launchFragmentInContainer(themeResId = MatR.style.Theme_MaterialComponents) { - WelcomeFragment( - createFactoryFor(mockIdentityVerificationViewModel), - mockVerificationResultCallback - ) - }.onFragment { - val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) - - navController.setGraph(R.navigation.identity_verification_nav_graph) - - navController.setCurrentDestination(R.id.fragment_welcome) - - Navigation.setViewNavController(it.requireView(), navController) - - block(FragmentWelcomeBinding.bind(it.requireView()), navController) - } - } - - private companion object { - const val temporaryKey = "fskt_1234" - val logo = mock() - - val contractArgs = ContractArgs( - temporaryKey = temporaryKey, - verificationId = "iv_1234", - maxNetworkRetries = 0, - workspaceLogo = logo - ) - } -} \ No newline at end of file