diff --git a/.github/workflows/release_sdk.yaml b/.github/workflows/release_sdk.yaml index 4807ba912..76ee3bbc8 100644 --- a/.github/workflows/release_sdk.yaml +++ b/.github/workflows/release_sdk.yaml @@ -131,6 +131,6 @@ jobs: git config --local user.email "${{ github.actor }}@users.noreply.github.com" git config --local user.name "${{ github.actor }}" - git add VERSION + git add ./lib/VERSION git commit -m "Prepare for next development iteration" git push diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1b819ed..a3b3d4fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,25 @@ # Changelog -## 10.0.0-beta05 (unreleased) +## 10.0.0-beta05 ### Added +- Add helper functions which return a Flow of the latest JobStatus for a Job until it is complete +- Add a `JobStatusResponse` interface +- Enhanced KYC Async API endpoint ### Fixed +- Fixed a bug where `id_info` was not included in Document Verification network requests ### Changed - Kotlin 1.9 +- Updated API key exception error message to be actionable +- `SmileID.useSandbox` getter is now publicly accessible +- Bump Sentry to 6.25.2 ### Removed +- Removed polling from SmartSelfie Authentication, Document Verification, and Biometric KYC. The + returned `SmileIDResult`s will now contain only the immediate result of job status without waiting + for job completion ## 10.0.0-beta04 diff --git a/README.md b/README.md index 026ce7e5c..ab9a84e6b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Smile ID Android SDK +

+ +

+ [![Build](https://github.com/smileidentity/android/actions/workflows/build.yaml/badge.svg)](https://github.com/smileidentity/android/actions/workflows/build.yaml) [![Maven Central](https://img.shields.io/maven-central/v/com.smileidentity/android-sdk)](https://mvnrepository.com/artifact/com.smileidentity/android-sdk) [![Sonatype Nexus (Snapshots)](https://img.shields.io/nexus/s/com.smileidentity/android-sdk?server=https%3A%2F%2Foss.sonatype.org)](https://oss.sonatype.org/content/repositories/snapshots/com/smileidentity/android-sdk/) @@ -12,18 +16,17 @@ If you havenā€™t already, [sign up](https://www.usesmileid.com/schedule-a-demo/) for a free Smile ID account, which comes with Sandbox access. -Please see [CHANGELOG.md](CHANGELOG.md) or -[Releases](https://github.com/smileidentity/android/releases) for the most recent version and +Please see [CHANGELOG.md](CHANGELOG.md) or +[Releases](https://github.com/smileidentity/android/releases) for the most recent version and release notes - ## Getting Started Full documentation is available at https://docs.usesmileid.com/integration-options/mobile Javadocs are available at https://javadoc.io/doc/com.smileidentity/android-sdk/latest/index.html -The [sample app](sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt) included in +The [sample app](sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt) included in this repo is a good reference implementation #### 0. Requirements @@ -59,9 +62,10 @@ All UI functionality is exposed via either Jetpack Compose or Fragments #### Jetpack Compose -All Composables are available under the `SmileID` object. +All Composables are available under the `SmileID` object. e.g. + ```kotlin SmileID.SmartSelfieEnrollment() SmileID.SmartSelfieAuthentication() diff --git a/lib/src/androidTest/java/com/smileidentity/compose/components/LoadingButtonTest.kt b/lib/src/androidTest/java/com/smileidentity/compose/components/LoadingButtonTest.kt new file mode 100644 index 000000000..3f486ba23 --- /dev/null +++ b/lib/src/androidTest/java/com/smileidentity/compose/components/LoadingButtonTest.kt @@ -0,0 +1,58 @@ +package com.smileidentity.compose.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert +import org.junit.Rule +import org.junit.Test + +class LoadingButtonTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testContinueButtonIsClickable() { + // given + var callbackInvoked = false + val onConfirmButtonClicked = { callbackInvoked = true } + val continueButtonText = "Continue" + + // when + composeTestRule.setContent { + LoadingButton( + continueButtonText, + onClick = onConfirmButtonClicked, + ) + } + composeTestRule.onNodeWithText(continueButtonText).performClick() + + // then + Assert.assertTrue(callbackInvoked) + } + + @Test + fun testContinueButtonShowsLoadingStateWhenClicked() { + // given + var callbackInvoked = false + val onConfirmButtonClicked = { callbackInvoked = true } + val continueButtonText = "Continue" + + // when + composeTestRule.setContent { + LoadingButton( + continueButtonText, + onClick = onConfirmButtonClicked, + ) + } + composeTestRule.onNodeWithText(continueButtonText).performClick() + + // then + Assert.assertTrue(callbackInvoked) + composeTestRule.onNodeWithText(continueButtonText).assertIsNotDisplayed() + composeTestRule.onNodeWithTag("circular_loading_indicator").assertIsDisplayed() + } +} diff --git a/lib/src/main/java/com/smileidentity/SmileID.kt b/lib/src/main/java/com/smileidentity/SmileID.kt index 46dbf2e20..f7cf7a333 100644 --- a/lib/src/main/java/com/smileidentity/SmileID.kt +++ b/lib/src/main/java/com/smileidentity/SmileID.kt @@ -27,14 +27,15 @@ import java.util.concurrent.TimeUnit @Suppress("unused", "RemoveRedundantQualifierName") object SmileID { @JvmStatic - lateinit var api: SmileIDService private set + lateinit var api: SmileIDService internal set val moshi: Moshi = initMoshi() // Initialized immediately so it can be used to parse Config lateinit var config: Config private lateinit var retrofit: Retrofit // Can't use lateinit on primitives, this default will be overwritten as soon as init is called - internal var useSandbox: Boolean = true + var useSandbox: Boolean = true + private set internal var apiKey: String? = null diff --git a/lib/src/main/java/com/smileidentity/compose/components/LoadingButton.kt b/lib/src/main/java/com/smileidentity/compose/components/LoadingButton.kt new file mode 100644 index 000000000..951dc9d5d --- /dev/null +++ b/lib/src/main/java/com/smileidentity/compose/components/LoadingButton.kt @@ -0,0 +1,54 @@ +package com.smileidentity.compose.components + +import androidx.compose.foundation.layout.Box +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import com.smileidentity.R +import com.smileidentity.compose.preview.SmilePreviews + +@Composable +fun LoadingButton( + buttonText: String, + modifier: Modifier = Modifier, + loading: Boolean = false, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + enabled = !loading, + ) { + Box { + if (loading) { + CircularProgressIndicator( + color = colorResource(id = R.color.si_color_accent), + strokeWidth = 2.dp, + modifier = Modifier + .size(15.dp) + .align(Alignment.Center) + .testTag("circular_loading_indicator"), + ) + } else { + Text(text = buttonText) + } + } + } +} + +@SmilePreviews +@Composable +fun LoadingButtonPreview() { + LoadingButton( + buttonText = "Continue", + onClick = {}, + ) +} diff --git a/lib/src/main/java/com/smileidentity/models/Authentication.kt b/lib/src/main/java/com/smileidentity/models/Authentication.kt index ffc7874e2..989a2f7d3 100644 --- a/lib/src/main/java/com/smileidentity/models/Authentication.kt +++ b/lib/src/main/java/com/smileidentity/models/Authentication.kt @@ -33,7 +33,7 @@ import kotlinx.parcelize.Parcelize @Parcelize @JsonClass(generateAdapter = true) data class AuthenticationRequest( - @Json(name = "job_type") val jobType: JobType, + @Json(name = "job_type") val jobType: JobType? = null, @Json(name = "enrollment") val enrollment: Boolean = jobType == SmartSelfieEnrollment, @Json(name = "country") val country: String? = null, @Json(name = "id_type") val idType: String? = null, @@ -50,6 +50,9 @@ data class AuthenticationRequest( * [consentInfo] is only populated when a country and ID type are provided in the * [AuthenticationRequest]. To get information about *all* countries and ID types instead, use * [com.smileidentity.networking.SmileIDService.getProductsConfig] + * + * [timestamp] is *not* a [java.util.Date] because technically, any arbitrary value could have been + * passed to it. This applies to all other timestamp fields in the SDK. */ @Parcelize @JsonClass(generateAdapter = true) @@ -58,6 +61,7 @@ data class AuthenticationResponse( @Json(name = "signature") val signature: String, @Json(name = "timestamp") val timestamp: String, @Json(name = "partner_params") val partnerParams: PartnerParams, + @Json(name = "callback_url") val callbackUrl: String? = null, @Json(name = "consent_info") val consentInfo: ConsentInfo? = null, ) : Parcelable diff --git a/lib/src/main/java/com/smileidentity/models/EnhancedKyc.kt b/lib/src/main/java/com/smileidentity/models/EnhancedKyc.kt index 72b4c4580..37b3f07e8 100644 --- a/lib/src/main/java/com/smileidentity/models/EnhancedKyc.kt +++ b/lib/src/main/java/com/smileidentity/models/EnhancedKyc.kt @@ -20,6 +20,7 @@ data class EnhancedKycRequest( @Json(name = "dob") val dob: String? = null, @Json(name = "phone_number") val phoneNumber: String? = null, @Json(name = "bank_code") val bankCode: String? = null, + @Json(name = "callback_url") val callbackUrl: String? = null, @Json(name = "partner_params") val partnerParams: PartnerParams, @Json(name = "partner_id") val partnerId: String = SmileID.config.partnerId, @Json(name = "source_sdk") val sourceSdk: String = "android", @@ -44,3 +45,7 @@ data class EnhancedKycResponse( @Json(name = "DOB") val dob: String?, @Json(name = "Photo") val base64Photo: String?, ) : Parcelable + +@Parcelize +@JsonClass(generateAdapter = true) +data class EnhancedKycAsyncResponse(@Json(name = "success") val success: Boolean) : Parcelable diff --git a/lib/src/main/java/com/smileidentity/models/JobStatus.kt b/lib/src/main/java/com/smileidentity/models/JobStatus.kt index 0029db4f5..f5bc3a30e 100644 --- a/lib/src/main/java/com/smileidentity/models/JobStatus.kt +++ b/lib/src/main/java/com/smileidentity/models/JobStatus.kt @@ -22,41 +22,50 @@ data class JobStatusRequest( @Json(name = "signature") val signature: String = calculateSignature(timestamp), ) : Parcelable +interface JobStatusResponse { + val timestamp: String + val jobComplete: Boolean + val jobSuccess: Boolean + val code: Int + val result: JobResult? + val imageLinks: ImageLinks? +} + @Parcelize @JsonClass(generateAdapter = true) -data class JobStatusResponse( - @Json(name = "timestamp") val timestamp: String, - @Json(name = "job_complete") val jobComplete: Boolean, - @Json(name = "job_success") val jobSuccess: Boolean, - @Json(name = "code") val code: Int, - @Json(name = "result") val result: JobResult?, +data class SmartSelfieJobStatusResponse( + @Json(name = "timestamp") override val timestamp: String, + @Json(name = "job_complete") override val jobComplete: Boolean, + @Json(name = "job_success") override val jobSuccess: Boolean, + @Json(name = "code") override val code: Int, + @Json(name = "result") override val result: JobResult?, @Json(name = "history") val history: List?, - @Json(name = "image_links") val imageLinks: ImageLinks?, -) : Parcelable + @Json(name = "image_links") override val imageLinks: ImageLinks?, +) : JobStatusResponse, Parcelable @Parcelize @JsonClass(generateAdapter = true) data class DocVJobStatusResponse( - @Json(name = "timestamp") val timestamp: String, - @Json(name = "job_complete") val jobComplete: Boolean, - @Json(name = "job_success") val jobSuccess: Boolean, - @Json(name = "code") val code: Int, - @Json(name = "result") val result: JobResult?, + @Json(name = "timestamp") override val timestamp: String, + @Json(name = "job_complete") override val jobComplete: Boolean, + @Json(name = "job_success") override val jobSuccess: Boolean, + @Json(name = "code") override val code: Int, + @Json(name = "result") override val result: JobResult?, @Json(name = "history") val history: List?, - @Json(name = "image_links") val imageLinks: ImageLinks?, -) : Parcelable + @Json(name = "image_links") override val imageLinks: ImageLinks?, +) : JobStatusResponse, Parcelable @Parcelize @JsonClass(generateAdapter = true) data class BiometricKycJobStatusResponse( - @Json(name = "timestamp") val timestamp: String, - @Json(name = "job_complete") val jobComplete: Boolean, - @Json(name = "job_success") val jobSuccess: Boolean, - @Json(name = "code") val code: Int, - @Json(name = "result") val result: JobResult?, + @Json(name = "timestamp") override val timestamp: String, + @Json(name = "job_complete") override val jobComplete: Boolean, + @Json(name = "job_success") override val jobSuccess: Boolean, + @Json(name = "code") override val code: Int, + @Json(name = "result") override val result: JobResult?, @Json(name = "history") val history: List?, - @Json(name = "image_links") val imageLinks: ImageLinks?, -) : Parcelable + @Json(name = "image_links") override val imageLinks: ImageLinks?, +) : JobStatusResponse, Parcelable /** * The job result might sometimes be a freeform text field instead of an object (i.e. when the diff --git a/lib/src/main/java/com/smileidentity/models/Models.kt b/lib/src/main/java/com/smileidentity/models/Models.kt index e37a13452..f65266ffd 100644 --- a/lib/src/main/java/com/smileidentity/models/Models.kt +++ b/lib/src/main/java/com/smileidentity/models/Models.kt @@ -3,10 +3,11 @@ package com.smileidentity.models import android.os.Parcelable +import com.smileidentity.util.randomJobId +import com.smileidentity.util.randomUserId import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize -import java.util.UUID @Suppress("CanBeParameter", "MemberVisibilityCanBePrivate") @Parcelize @@ -30,8 +31,8 @@ class SmileIDException(val details: Details) : Exception(details.message), Parce @Parcelize data class PartnerParams( val jobType: JobType? = null, - val jobId: String = UUID.randomUUID().toString(), - val userId: String = UUID.randomUUID().toString(), + val jobId: String = randomJobId(), + val userId: String = randomUserId(), val extras: Map = mapOf(), ) : Parcelable diff --git a/lib/src/main/java/com/smileidentity/networking/NetworkingUtil.kt b/lib/src/main/java/com/smileidentity/networking/NetworkingUtil.kt index 037c6dd8d..6f8891ea2 100644 --- a/lib/src/main/java/com/smileidentity/networking/NetworkingUtil.kt +++ b/lib/src/main/java/com/smileidentity/networking/NetworkingUtil.kt @@ -13,7 +13,12 @@ import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream fun calculateSignature(timestamp: String): String { - val apiKey = SmileID.apiKey ?: throw IllegalStateException("API key not set") + val apiKey = SmileID.apiKey ?: throw IllegalStateException( + """API key not set. If using the authToken from smile_config.json, ensure you have set the + |signature/timestamp properties on the request from the values returned by + |SmileID.authenticate.signature/timestamp + """.trimMargin().replace("\n", ""), + ) val hashContent = timestamp + SmileID.config.partnerId + "sid_request" return hashContent.encode().hmacSha256(apiKey.encode()).base64() } diff --git a/lib/src/main/java/com/smileidentity/networking/SmileIDService.kt b/lib/src/main/java/com/smileidentity/networking/SmileIDService.kt index 25ed165f7..a26c90569 100644 --- a/lib/src/main/java/com/smileidentity/networking/SmileIDService.kt +++ b/lib/src/main/java/com/smileidentity/networking/SmileIDService.kt @@ -1,9 +1,12 @@ +@file:Suppress("unused") + package com.smileidentity.networking import com.smileidentity.models.AuthenticationRequest import com.smileidentity.models.AuthenticationResponse import com.smileidentity.models.BiometricKycJobStatusResponse import com.smileidentity.models.DocVJobStatusResponse +import com.smileidentity.models.EnhancedKycAsyncResponse import com.smileidentity.models.EnhancedKycRequest import com.smileidentity.models.EnhancedKycResponse import com.smileidentity.models.JobStatusRequest @@ -13,14 +16,18 @@ import com.smileidentity.models.PrepUploadResponse import com.smileidentity.models.ProductsConfigRequest import com.smileidentity.models.ProductsConfigResponse import com.smileidentity.models.ServicesResponse +import com.smileidentity.models.SmartSelfieJobStatusResponse import com.smileidentity.models.UploadRequest +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.channelFlow import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Url +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds -@Suppress("unused") interface SmileIDService { /** * Returns a signature and timestamp that can be used to authenticate future requests. This is @@ -49,17 +56,32 @@ interface SmileIDService { * Query the Identity Information of an individual using their ID number from a supported ID * Type. Return the personal information of the individual found in the database of the ID * authority. + * + * This will be done synchronously, and the result will be returned in the response. If the ID + * provider is unavailable, the response will be an error. */ @POST("/v1/id_verification") suspend fun doEnhancedKyc(@Body request: EnhancedKycRequest): EnhancedKycResponse + /** + * Same as [doEnhancedKyc], but the final result is delivered the URL provided in the (required) + * [EnhancedKycRequest.callbackUrl] field. + * + * If the ID provider is unavailable, the response will be delivered to the callback URL once + * the ID provider is available again. + */ + @POST("/v1/async_id_verification") + suspend fun doEnhancedKycAsync(@Body request: EnhancedKycRequest): EnhancedKycAsyncResponse + /** * Fetches the status of a Job. This can be used to check if a Job is complete, and if so, * whether it was successful. This should be called when the Job is known to be a * SmartSelfie Authentication/Registration. */ @POST("/v1/job_status") - suspend fun getJobStatus(@Body request: JobStatusRequest): JobStatusResponse + suspend fun getSmartSelfieJobStatus( + @Body request: JobStatusRequest, + ): SmartSelfieJobStatusResponse /** * Fetches the status of a Job. This can be used to check if a Job is complete, and if so, @@ -85,6 +107,102 @@ interface SmileIDService { @POST("/v1/products_config") suspend fun getProductsConfig(@Body request: ProductsConfigRequest): ProductsConfigResponse + /** + * Returns supported products and metadata + */ @GET("/v1/services") suspend fun getServices(): ServicesResponse } + +/** + * Polls the server for the status of a Job until it is complete. This should be called after the + * Job has been submitted to the server. The returned flow will be updated with every job status + * response. The flow will complete when the job is complete, or the attempt limit is reached. + * If any exceptions occur, only the last one will be thrown. If there is a successful API response + * after an exception, the exception will be ignored. + * + * @param request The [JobStatusRequest] to make to the server + * @param interval The interval between each poll + * @param numAttempts The number of times to poll before giving up + */ +fun SmileIDService.pollSmartSelfieJobStatus( + request: JobStatusRequest, + interval: Duration = 1.seconds, + numAttempts: Int = 30, +) = poll(interval, numAttempts) { getSmartSelfieJobStatus(request) } + +/** + * Polls the server for the status of a Job until it is complete. This should be called after the + * Job has been submitted to the server. The returned flow will be updated with every job status + * response. The flow will complete when the job is complete, or the attempt limit is reached. + * If any exceptions occur, only the last one will be thrown. If there is a successful API response + * after an exception, the exception will be ignored. + * + * @param request The [JobStatusRequest] to make to the server + * @param interval The interval between each poll + * @param numAttempts The number of times to poll before giving up + */ +fun SmileIDService.pollDocVJobStatus( + request: JobStatusRequest, + interval: Duration = 1.seconds, + numAttempts: Int = 30, +) = poll(interval, numAttempts) { getDocVJobStatus(request) } + +/** + * Polls the server for the status of a Job until it is complete. This should be called after the + * Job has been submitted to the server. The returned flow will be updated with every job status + * response. The flow will complete when the job is complete, or the attempt limit is reached. + * If any exceptions occur, only the last one will be thrown. If there is a successful API response + * after an exception, the exception will be ignored. + * + * @param request The [JobStatusRequest] to make to the server + * @param interval The interval between each poll + * @param numAttempts The number of times to poll before giving up + */ +fun SmileIDService.pollBiometricKycJobStatus( + request: JobStatusRequest, + interval: Duration = 1.seconds, + numAttempts: Int = 30, +) = poll(interval, numAttempts) { getBiometricKycJobStatus(request) } + +/** + * This uses a generics (as compared to the interface as the return type of [action] directly) so + * that the higher level callers (defined above) have a concrete return type + * + * [channelFlow] is used instead of [kotlinx.coroutines.flow.flow] so that API calls continue to be + * made when the consumer processes slower than the producer + * + * It is recommended to collect this flow with [kotlinx.coroutines.flow.collectLatest] (note: if + * consuming slower than this is producing, the consumer coroutine will continue getting cancelled + * until the last value) since the flow will complete when the job is complete + * + * Alternatively, [kotlinx.coroutines.flow.collect] can be used along with + * [kotlinx.coroutines.flow.conflate] to drop older, non-consumed values when newer values are + * present + */ +internal fun poll( + interval: Duration, + numAttempts: Int, + action: suspend (attempt: Int) -> T, +) = channelFlow { + var latestError: Exception? = null + // TODO: Replace `until` with `..<` once ktlint-gradle plugin stops throwing an exception for it + // see: https://github.com/JLLeitschuh/ktlint-gradle/issues/692 + for (attempt in 0 until numAttempts) { + try { + val response = action(attempt) + send(response) + + // Reset the error if the API response was successful + latestError = null + + if (response.jobComplete) { + break + } + } catch (e: Exception) { + latestError = e + } + delay(interval) + } + latestError?.let { throw it } +} diff --git a/lib/src/main/java/com/smileidentity/results/SmileIDResult.kt b/lib/src/main/java/com/smileidentity/results/SmileIDResult.kt index 5249d1129..45503f226 100644 --- a/lib/src/main/java/com/smileidentity/results/SmileIDResult.kt +++ b/lib/src/main/java/com/smileidentity/results/SmileIDResult.kt @@ -5,7 +5,7 @@ import com.smileidentity.models.BiometricKycJobStatusResponse import com.smileidentity.models.DocVJobStatusResponse import com.smileidentity.models.EnhancedKycRequest import com.smileidentity.models.EnhancedKycResponse -import com.smileidentity.models.JobStatusResponse +import com.smileidentity.models.SmartSelfieJobStatusResponse import kotlinx.parcelize.Parcelize import java.io.File @@ -28,9 +28,9 @@ sealed interface SmileIDResult : Parcelable { * The flow was successful. The result is the value of type [T]. * * NB! The Job itself may or may not be complete yet. This can be checked with - * [com.smileidentity.models.JobStatusResponse.jobComplete]. If not yet complete, the job status - * will need to be fetched again later. If the job is complete, the final job success can be - * checked with [com.smileidentity.models.JobStatusResponse.jobSuccess]. + * [com.smileidentity.models.SmartSelfieJobStatusResponse.jobComplete]. If not yet complete, the + * job status will need to be fetched again later. If the job is complete, the final job success + * can be checked with [com.smileidentity.models.SmartSelfieJobStatusResponse.jobSuccess]. */ @Parcelize data class Success(val data: T) : SmileIDResult @@ -46,9 +46,9 @@ sealed interface SmileIDResult : Parcelable { /** * The result of a SmartSelfie capture and submission to the Smile ID API. Indicates that the selfie * capture and network requests were successful. The Job itself may or may not be complete yet. This - * can be checked with [JobStatusResponse.jobComplete]. If not yet complete, the job status will - * need to be fetched again later. If the job is complete, the final job success can be checked with - * [JobStatusResponse.jobSuccess]. + * can be checked with [SmartSelfieJobStatusResponse.jobComplete]. If not yet complete, the job + * status will need to be fetched again later. If the job is complete, the final job success can be + * checked with [SmartSelfieJobStatusResponse.jobSuccess]. * * If [jobStatusResponse] is null, that means submission to the API was skipped */ @@ -56,7 +56,7 @@ sealed interface SmileIDResult : Parcelable { data class SmartSelfieResult( val selfieFile: File, val livenessFiles: List, - val jobStatusResponse: JobStatusResponse?, + val jobStatusResponse: SmartSelfieJobStatusResponse?, ) : Parcelable /** diff --git a/lib/src/main/java/com/smileidentity/util/Util.kt b/lib/src/main/java/com/smileidentity/util/Util.kt index 73419fa97..e1b748a70 100644 --- a/lib/src/main/java/com/smileidentity/util/Util.kt +++ b/lib/src/main/java/com/smileidentity/util/Util.kt @@ -244,30 +244,28 @@ internal fun createDocumentFile() = createSmileTempFile("document") * * @param proxy Callback to be invoked with the exception */ -fun getExceptionHandler(proxy: (Throwable) -> Unit): CoroutineExceptionHandler { - return CoroutineExceptionHandler { _, throwable -> - Timber.e(throwable, "Error during coroutine execution") - val converted = if (throwable is HttpException) { - val adapter = moshi.adapter(SmileIDException.Details::class.java) - try { - val details = adapter.fromJson(throwable.response()?.errorBody()?.source()!!)!! - SmileIDException(details) - } catch (e: Exception) { - Timber.w(e, "Unable to convert HttpException to SmileIDException") +fun getExceptionHandler(proxy: (Throwable) -> Unit) = CoroutineExceptionHandler { _, throwable -> + Timber.e(throwable, "Error during coroutine execution") + val converted = if (throwable is HttpException) { + val adapter = moshi.adapter(SmileIDException.Details::class.java) + try { + val details = adapter.fromJson(throwable.response()?.errorBody()?.source()!!)!! + SmileIDException(details) + } catch (e: Exception) { + Timber.w(e, "Unable to convert HttpException to SmileIDException") - // Report the *conversion* error to Sentry, rather than the original error - SmileIDCrashReporting.hub.captureException(e) + // Report the *conversion* error to Sentry, rather than the original error + SmileIDCrashReporting.hub.captureException(e) - // More informative to pass back the original exception than the conversion error - throwable - } - } else { - // Unexpected error, report to Sentry - SmileIDCrashReporting.hub.captureException(throwable) + // More informative to pass back the original exception than the conversion error throwable } - proxy(converted) + } else { + // Unexpected error, report to Sentry + SmileIDCrashReporting.hub.captureException(throwable) + throwable } + proxy(converted) } fun randomId(prefix: String) = prefix + "-" + java.util.UUID.randomUUID().toString() diff --git a/lib/src/main/java/com/smileidentity/viewmodel/BiometricKycViewModel.kt b/lib/src/main/java/com/smileidentity/viewmodel/BiometricKycViewModel.kt index 1c5095541..809f9a448 100644 --- a/lib/src/main/java/com/smileidentity/viewmodel/BiometricKycViewModel.kt +++ b/lib/src/main/java/com/smileidentity/viewmodel/BiometricKycViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import com.smileidentity.SmileID import com.smileidentity.compose.components.ProcessingState import com.smileidentity.models.AuthenticationRequest -import com.smileidentity.models.BiometricKycJobStatusResponse import com.smileidentity.models.IdInfo import com.smileidentity.models.JobStatusRequest import com.smileidentity.models.JobType @@ -17,14 +16,12 @@ import com.smileidentity.results.BiometricKycResult import com.smileidentity.results.SmileIDCallback import com.smileidentity.results.SmileIDResult import com.smileidentity.util.getExceptionHandler -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber import java.io.File -import kotlin.time.Duration.Companion.seconds data class BiometricKycUiState( val showLoading: Boolean = true, @@ -127,17 +124,7 @@ class BiometricKycViewModel( timestamp = authResponse.timestamp, ) - lateinit var jobStatusResponse: BiometricKycJobStatusResponse - val jobStatusPollDelay = 1.seconds - for (i in 1..10) { - Timber.v("Job Status poll attempt #$i in $jobStatusPollDelay") - delay(jobStatusPollDelay) - jobStatusResponse = SmileID.api.getBiometricKycJobStatus(jobStatusRequest) - Timber.v("Job Status Response: $jobStatusResponse") - if (jobStatusResponse.jobComplete) { - break - } - } + val jobStatusResponse = SmileID.api.getBiometricKycJobStatus(jobStatusRequest) result = SmileIDResult.Success( BiometricKycResult( selfieFile, @@ -145,7 +132,6 @@ class BiometricKycViewModel( jobStatusResponse, ), ) - _uiState.update { it.copy(processingState = ProcessingState.Success) } } } diff --git a/lib/src/main/java/com/smileidentity/viewmodel/DocumentViewModel.kt b/lib/src/main/java/com/smileidentity/viewmodel/DocumentViewModel.kt index 30298de58..19b276ddc 100644 --- a/lib/src/main/java/com/smileidentity/viewmodel/DocumentViewModel.kt +++ b/lib/src/main/java/com/smileidentity/viewmodel/DocumentViewModel.kt @@ -8,8 +8,8 @@ import com.smileidentity.R import com.smileidentity.SmileID import com.smileidentity.compose.components.ProcessingState import com.smileidentity.models.AuthenticationRequest -import com.smileidentity.models.DocVJobStatusResponse import com.smileidentity.models.Document +import com.smileidentity.models.IdInfo import com.smileidentity.models.JobStatusRequest import com.smileidentity.models.JobType import com.smileidentity.models.PrepUploadRequest @@ -25,7 +25,7 @@ import com.smileidentity.util.getExceptionHandler import com.smileidentity.util.postProcessImage import com.ujizin.camposer.state.CameraState import com.ujizin.camposer.state.ImageCaptureResult -import kotlinx.coroutines.delay +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -35,7 +35,6 @@ import java.io.File import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine -import kotlin.time.Duration.Companion.seconds data class DocumentUiState( val frontDocumentImageToConfirm: File? = null, @@ -126,7 +125,7 @@ class DocumentViewModel( } } - fun submitJob(documentFrontFile: File, documentBackFile: File? = null) { + fun submitJob(documentFrontFile: File, documentBackFile: File? = null): Job { _uiState.update { it.copy(processingState = ProcessingState.InProgress) } val proxy = { e: Throwable -> result = SmileIDResult.Error(e) @@ -137,7 +136,7 @@ class DocumentViewModel( ) } } - viewModelScope.launch(getExceptionHandler(proxy)) { + return viewModelScope.launch(getExceptionHandler(proxy)) { val authRequest = AuthenticationRequest( jobType = JobType.DocumentVerification, enrollment = false, @@ -161,6 +160,7 @@ class DocumentViewModel( ) val uploadRequest = UploadRequest( images = listOfNotNull(frontImageInfo, backImageInfo, selfieImageInfo), + idInfo = IdInfo(idType.countryCode, idType.documentType), ) SmileID.api.upload(prepUploadResponse.uploadUrl, uploadRequest) Timber.d("Upload finished") @@ -173,17 +173,7 @@ class DocumentViewModel( timestamp = authResponse.timestamp, ) - lateinit var jobStatusResponse: DocVJobStatusResponse - val jobStatusPollDelay = 1.seconds - for (i in 1..10) { - Timber.v("Job Status poll attempt #$i in $jobStatusPollDelay") - delay(jobStatusPollDelay) - jobStatusResponse = SmileID.api.getDocVJobStatus(jobStatusRequest) - Timber.v("Job Status Response: $jobStatusResponse") - if (jobStatusResponse.jobComplete) { - break - } - } + val jobStatusResponse = SmileID.api.getDocVJobStatus(jobStatusRequest) result = SmileIDResult.Success( DocumentVerificationResult( selfieFile = selfieImageInfo.image, diff --git a/lib/src/main/java/com/smileidentity/viewmodel/SelfieViewModel.kt b/lib/src/main/java/com/smileidentity/viewmodel/SelfieViewModel.kt index c77076c03..3676f6575 100644 --- a/lib/src/main/java/com/smileidentity/viewmodel/SelfieViewModel.kt +++ b/lib/src/main/java/com/smileidentity/viewmodel/SelfieViewModel.kt @@ -16,7 +16,6 @@ import com.smileidentity.SmileID import com.smileidentity.compose.components.ProcessingState import com.smileidentity.models.AuthenticationRequest import com.smileidentity.models.JobStatusRequest -import com.smileidentity.models.JobStatusResponse import com.smileidentity.models.JobType.SmartSelfieAuthentication import com.smileidentity.models.JobType.SmartSelfieEnrollment import com.smileidentity.models.PrepUploadRequest @@ -33,7 +32,6 @@ import com.smileidentity.util.createSelfieFile import com.smileidentity.util.getExceptionHandler import com.smileidentity.util.postProcessImageBitmap import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow @@ -45,7 +43,6 @@ import timber.log.Timber import java.io.File import kotlin.math.absoluteValue import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds private val UI_DEBOUNCE_DURATION = 250.milliseconds private const val INTRA_IMAGE_MIN_DELAY_MS = 350 @@ -296,17 +293,7 @@ class SelfieViewModel( timestamp = authResponse.timestamp, ) - lateinit var jobStatusResponse: JobStatusResponse - val jobStatusPollDelay = 1.seconds - for (i in 1..10) { - Timber.v("Job Status poll attempt #$i in $jobStatusPollDelay") - delay(jobStatusPollDelay) - jobStatusResponse = SmileID.api.getJobStatus(jobStatusRequest) - Timber.v("Job Status Response: $jobStatusResponse") - if (jobStatusResponse.jobComplete) { - break - } - } + val jobStatusResponse = SmileID.api.getSmartSelfieJobStatus(jobStatusRequest) result = SmileIDResult.Success( SmartSelfieResult( selfieFile, diff --git a/lib/src/test/java/com/smileidentity/networking/EnhancedKycTest.kt b/lib/src/test/java/com/smileidentity/networking/EnhancedKycTest.kt new file mode 100644 index 000000000..d7e2e45b4 --- /dev/null +++ b/lib/src/test/java/com/smileidentity/networking/EnhancedKycTest.kt @@ -0,0 +1,42 @@ +package com.smileidentity.networking + +import com.smileidentity.SmileID +import com.smileidentity.models.EnhancedKycAsyncResponse +import com.smileidentity.models.EnhancedKycRequest +import com.smileidentity.models.JobType.EnhancedKyc +import com.smileidentity.models.PartnerParams +import org.junit.Test + +class EnhancedKycTest { + @Test + fun shouldDecodeEnhancedKycAsyncResponseJson() { + // given + val json = """{"success": true}""" + + // when + val response = SmileID.moshi.adapter(EnhancedKycAsyncResponse::class.java).fromJson(json) + + // then + assert(response!!.success) + } + + @Test + fun shouldIncludeCallbackUrlForEnhancedKycAsync() { + // given + val request = EnhancedKycRequest( + country = "country", + idType = "idType", + idNumber = "idNumber", + callbackUrl = "callbackUrl", + partnerId = "partnerId", + signature = "signature", + partnerParams = PartnerParams(EnhancedKyc), + ) + + // when + val json = SmileID.moshi.adapter(EnhancedKycRequest::class.java).toJson(request) + + // then + assert(json.contains("callback_url")) + } +} diff --git a/lib/src/test/java/com/smileidentity/networking/SmileIDServiceTest.kt b/lib/src/test/java/com/smileidentity/networking/SmileIDServiceTest.kt new file mode 100644 index 000000000..0837ff060 --- /dev/null +++ b/lib/src/test/java/com/smileidentity/networking/SmileIDServiceTest.kt @@ -0,0 +1,83 @@ +package com.smileidentity.networking + +import com.smileidentity.models.JobStatusResponse +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.testTimeSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +class SmileIDServiceTest { + @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) + @Test + fun `poll should wait between attempts`() = runTest { + // given + val delay = 1.seconds + val incompleteResponse = mockk { every { jobComplete } returns false } + val completeResponse = mockk { every { jobComplete } returns true } + val responses = listOf(incompleteResponse, incompleteResponse, completeResponse) + val expectedTotalTime = delay * (responses.size - 1) + + // when + val duration = testTimeSource.measureTime { + poll(delay, Int.MAX_VALUE) { responses[it] }.collect { } + } + + // then + assertEquals(expectedTotalTime, duration) + } + + @Test + fun `poll should stop after numAttempts`() = runTest { + // given + val numAttempts = 2 + val incompleteResponse = mockk { every { jobComplete } returns false } + val responses = listOf(incompleteResponse, incompleteResponse, incompleteResponse) + var counter = 0 + + // when + poll(1.seconds, numAttempts) { responses[it] }.collect { counter++ } + + // then + assertEquals(numAttempts, counter) + } + + @Test + fun `poll should stop after job is complete`() = runTest { + // given + val incompleteResponse = mockk { every { jobComplete } returns false } + val completeResponse = mockk { every { jobComplete } returns true } + // the last incomplete response should never be emitted + val responses = listOf(incompleteResponse, completeResponse, incompleteResponse) + + // when + val lastResult = poll(1.seconds, Int.MAX_VALUE) { responses[it] }.toList().last() + + // then + assertTrue(lastResult.jobComplete) + } + + @Test + fun `poll should throw if attempts fail`() = runTest { + // given + val expectedException = Exception("test") + + // when + val error = try { + poll(1.seconds, 100) { throw expectedException }.collect { } + null + } catch (e: Exception) { + e + } + + // then + assertEquals(expectedException.message, error?.message) + } +} diff --git a/lib/src/test/java/com/smileidentity/viewmodel/DocumentViewModelTest.kt b/lib/src/test/java/com/smileidentity/viewmodel/DocumentViewModelTest.kt index 3ed15448d..1c9c6e4c5 100644 --- a/lib/src/test/java/com/smileidentity/viewmodel/DocumentViewModelTest.kt +++ b/lib/src/test/java/com/smileidentity/viewmodel/DocumentViewModelTest.kt @@ -1,25 +1,57 @@ package com.smileidentity.viewmodel +import com.smileidentity.SmileID +import com.smileidentity.compose.components.ProcessingState +import com.smileidentity.models.AuthenticationResponse +import com.smileidentity.models.Config import com.smileidentity.models.Document +import com.smileidentity.models.JobType +import com.smileidentity.models.PartnerParams +import com.smileidentity.models.PrepUploadResponse +import com.smileidentity.models.UploadRequest import com.smileidentity.util.randomJobId import com.smileidentity.util.randomUserId +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Before import org.junit.Test +import java.io.File @OptIn(ExperimentalCoroutinesApi::class) class DocumentViewModelTest { private lateinit var subject: DocumentViewModel + private val documentFrontFile = File.createTempFile("documentFront", ".jpg") + private val selfieFile = File.createTempFile("selfie", ".jpg") + private val document = Document("KE", "ID_CARD") + @Before fun setup() { Dispatchers.setMain(Dispatchers.Unconfined) - subject = DocumentViewModel(randomUserId(), randomJobId(), Document("KE", "ID_CARD")) + subject = DocumentViewModel( + randomUserId(), + randomJobId(), + document, + selfieFile = selfieFile, + ) + SmileID.config = Config( + partnerId = "partnerId", + authToken = "authToken", + prodBaseUrl = "prodBaseUrl", + sandboxBaseUrl = "sandboxBaseUrl", + ) } @After @@ -33,4 +65,63 @@ class DocumentViewModelTest { assertEquals(null, uiState.frontDocumentImageToConfirm) assertEquals(null, uiState.errorMessage) } + + @Test + fun `submitJob should move processingState to InProgress`() { + // when + SmileID.api = mockk(relaxed = true) + coEvery { SmileID.api.authenticate(any()) } coAnswers { + delay(1000) + throw RuntimeException("unreachable") + } + subject.submitJob(documentFrontFile) + + // then + // the submitJob coroutine won't have finished executing yet, so should still be processing + assertEquals(ProcessingState.InProgress, subject.uiState.value.processingState) + } + + @Test + fun `submitJob should move processingState to Error`() = runTest { + // given + SmileID.api = mockk() + coEvery { SmileID.api.authenticate(any()) } throws RuntimeException() + + // when + subject.submitJob(documentFrontFile).join() + + // then + assertEquals(ProcessingState.Error, subject.uiState.value.processingState) + } + + @Test + fun `submitJob should include idInfo`() = runTest { + // given + SmileID.api = mockk() + coEvery { SmileID.api.authenticate(any()) } returns AuthenticationResponse( + success = true, + signature = "signature", + timestamp = "timestamp", + partnerParams = PartnerParams(jobType = JobType.DocumentVerification), + ) + + coEvery { SmileID.api.prepUpload(any()) } returns PrepUploadResponse( + code = 0, + refId = "refId", + uploadUrl = "uploadUrl", + smileJobId = "smileJobId", + cameraConfig = null, + ) + + val uploadBodySlot = slot() + coEvery { SmileID.api.upload(any(), capture(uploadBodySlot)) } just Runs + + // when + subject.submitJob(documentFrontFile).join() + + // then + assertNotNull(uploadBodySlot.captured.idInfo) + assertEquals(document.countryCode, uploadBodySlot.captured.idInfo?.country) + assertEquals(document.documentType, uploadBodySlot.captured.idInfo?.idType) + } } diff --git a/sample/sample.gradle.kts b/sample/sample.gradle.kts index 52bbd666c..3e7c44115 100644 --- a/sample/sample.gradle.kts +++ b/sample/sample.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.ktlint) + alias(libs.plugins.moshix) alias(libs.plugins.parcelize) } @@ -17,7 +18,7 @@ android { targetSdk = 34 versionCode = findProperty("VERSION_CODE")?.toString()?.toInt() ?: 1 // Include the SDK version in the app version name - versionName = "1.2.0_sdk-" + project(":lib").version.toString() + versionName = "1.3_sdk-" + project(":lib").version.toString() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -39,6 +40,10 @@ android { } buildTypes { + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "_debug" + } release { isMinifyEnabled = false isDebuggable = false diff --git a/sample/src/androidTest/java/com/smileidentity/sample/ExampleInstrumentedTest.kt b/sample/src/androidTest/java/com/smileidentity/sample/ExampleInstrumentedTest.kt deleted file mode 100644 index 2e0ac0ee7..000000000 --- a/sample/src/androidTest/java/com/smileidentity/sample/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.smileidentity.sample - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.smileidentity.sample", appContext.packageName) - } -} diff --git a/sample/src/main/java/com/smileidentity/sample/Screen.kt b/sample/src/main/java/com/smileidentity/sample/Screen.kt index ce4567f20..6bd3f294d 100644 --- a/sample/src/main/java/com/smileidentity/sample/Screen.kt +++ b/sample/src/main/java/com/smileidentity/sample/Screen.kt @@ -6,12 +6,12 @@ import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.List import androidx.compose.material.icons.outlined.Settings -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope import androidx.compose.ui.graphics.vector.ImageVector sealed interface Screen { @@ -19,63 +19,66 @@ sealed interface Screen { val label: Int } -sealed class ProductScreen( +enum class ProductScreen( override val route: String, @StringRes override val label: Int, @DrawableRes val icon: Int, ) : Screen { - object SmartSelfieEnrollment : ProductScreen( + SmartSelfieEnrollment( "smart_selfie_enrollment", com.smileidentity.R.string.si_smart_selfie_enrollment_product_name, com.smileidentity.R.drawable.si_smart_selfie_instructions_hero, - ) - - object SmartSelfieAuthentication : ProductScreen( + ), + SmartSelfieAuthentication( "smart_selfie_authentication", com.smileidentity.R.string.si_smart_selfie_authentication_product_name, com.smileidentity.R.drawable.si_smart_selfie_instructions_hero, - ) - - object EnhancedKyc : ProductScreen( + ), + EnhancedKyc( "enhanced_kyc", R.string.enhanced_kyc_product_name, R.drawable.enhanced_kyc, - ) - - object BiometricKyc : ProductScreen( + ), + BiometricKyc( "biometric_kyc", com.smileidentity.R.string.si_biometric_kyc_product_name, R.drawable.biometric_kyc, - ) - - object DocumentVerification : ProductScreen( + ), + DocumentVerification( "document_verification", com.smileidentity.R.string.si_doc_v_product_name, com.smileidentity.R.drawable.si_doc_v_instructions_hero, - ) + ), } -sealed class BottomNavigationScreen( +enum class BottomNavigationScreen( override val route: String, @StringRes override val label: Int, val selectedIcon: ImageVector, val unselectedIcon: ImageVector, ) : Screen { - object Home : BottomNavigationScreen("home", R.string.home, Filled.Home, Outlined.Home) - object Resources : - BottomNavigationScreen("resources", R.string.resources, Filled.Info, Outlined.Info) - - object AboutUs : - BottomNavigationScreen("about_us", R.string.about_us, Filled.Settings, Outlined.Settings) -} - -object BottomNavigationScreenSaver : Saver { - override fun restore(value: String): BottomNavigationScreen = when (value) { - BottomNavigationScreen.Home.route -> BottomNavigationScreen.Home - BottomNavigationScreen.Resources.route -> BottomNavigationScreen.Resources - BottomNavigationScreen.AboutUs.route -> BottomNavigationScreen.AboutUs - else -> throw IllegalArgumentException("Unknown route: $value") - } - - override fun SaverScope.save(value: BottomNavigationScreen): String = value.route + Home( + "home", + R.string.home, + Filled.Home, + Outlined.Home, + ), + Jobs( + "jobs", + R.string.jobs, + Filled.List, + Outlined.List, + ), + Resources( + "resources", + R.string.resources, + Filled.Info, + Outlined.Info, + ), + AboutUs( + "about_us", + R.string.about_us, + Filled.Settings, + Outlined.Settings, + ), } diff --git a/sample/src/main/java/com/smileidentity/sample/Utils.kt b/sample/src/main/java/com/smileidentity/sample/Utils.kt index 4edfb92ed..20915e5db 100644 --- a/sample/src/main/java/com/smileidentity/sample/Utils.kt +++ b/sample/src/main/java/com/smileidentity/sample/Utils.kt @@ -5,6 +5,7 @@ import android.widget.Toast import androidx.annotation.StringRes import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import com.smileidentity.models.JobType import com.smileidentity.sample.compose.components.SearchableInputFieldItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -60,6 +61,17 @@ fun jobResultMessageBuilder( return message.toString() } +val JobType.label: Int + @StringRes + get() = when (this) { + JobType.SmartSelfieEnrollment -> com.smileidentity.R.string.si_smart_selfie_enrollment_product_name // ktlint-disable max-line-length + JobType.SmartSelfieAuthentication -> com.smileidentity.R.string.si_smart_selfie_authentication_product_name // ktlint-disable max-line-length + JobType.EnhancedKyc -> R.string.enhanced_kyc_product_name + JobType.BiometricKyc -> com.smileidentity.R.string.si_biometric_kyc_product_name + JobType.DocumentVerification -> com.smileidentity.R.string.si_doc_v_product_name + JobType.Unknown -> -1 + } + val countryDetails = mapOf( "AO" to SearchableInputFieldItem("AO", "Angola", "šŸ‡¦šŸ‡“"), "BF" to SearchableInputFieldItem("BF", "Burkina Faso", "šŸ‡§šŸ‡«"), diff --git a/sample/src/main/java/com/smileidentity/sample/activity/JavaActivity.java b/sample/src/main/java/com/smileidentity/sample/activity/JavaActivity.java index c1bab23f1..71dd3f1ba 100644 --- a/sample/src/main/java/com/smileidentity/sample/activity/JavaActivity.java +++ b/sample/src/main/java/com/smileidentity/sample/activity/JavaActivity.java @@ -13,7 +13,7 @@ import com.smileidentity.fragment.SmartSelfieAuthenticationFragment; import com.smileidentity.fragment.SmartSelfieEnrollmentFragment; import com.smileidentity.models.Document; -import com.smileidentity.models.JobStatusResponse; +import com.smileidentity.models.SmartSelfieJobStatusResponse; import com.smileidentity.results.DocumentVerificationResult; import com.smileidentity.results.SmartSelfieResult; import com.smileidentity.results.SmileIDResult; @@ -59,7 +59,7 @@ private void doSmartSelfieEnrollment() { if (smartSelfieResult instanceof SmileIDResult.Success successResult) { File selfieFile = successResult.getData().getSelfieFile(); List livenessFiles = successResult.getData().getLivenessFiles(); - JobStatusResponse jobStatusResponse = successResult.getData().getJobStatusResponse(); + SmartSelfieJobStatusResponse jobStatusResponse = successResult.getData().getJobStatusResponse(); // Note: Although the API submission is successful, the job status response // may indicate that the job is still in progress or failed. You should // check the job status response to determine the final status of the job. diff --git a/sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt b/sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt index 70d9f7b37..87eb85391 100644 --- a/sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt +++ b/sample/src/main/java/com/smileidentity/sample/compose/MainScreen.kt @@ -3,11 +3,15 @@ package com.smileidentity.sample.compose import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -18,6 +22,7 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PlainTooltipBox import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost @@ -26,6 +31,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,9 +43,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -49,287 +57,161 @@ import com.smileidentity.compose.SmartSelfieAuthentication import com.smileidentity.compose.SmartSelfieEnrollment import com.smileidentity.models.Document import com.smileidentity.models.IdInfo -import com.smileidentity.models.JobResult import com.smileidentity.models.JobType -import com.smileidentity.results.SmileIDResult import com.smileidentity.sample.BottomNavigationScreen import com.smileidentity.sample.ProductScreen import com.smileidentity.sample.R -import com.smileidentity.sample.Screen import com.smileidentity.sample.compose.components.IdTypeSelectorAndFieldInputScreen -import com.smileidentity.sample.jobResultMessageBuilder -import com.smileidentity.sample.showSnackbar +import com.smileidentity.sample.compose.jobs.OrchestratedJobsScreen +import com.smileidentity.sample.viewmodel.MainScreenUiState.Companion.startScreen +import com.smileidentity.sample.viewmodel.MainScreenViewModel import com.smileidentity.util.randomJobId import com.smileidentity.util.randomUserId -import timber.log.Timber +import com.smileidentity.viewmodel.viewModelFactory +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch import java.net.URL -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class) @Preview @Composable -fun MainScreen() { +fun MainScreen( + viewModel: MainScreenViewModel = viewModel( + factory = viewModelFactory { MainScreenViewModel() }, + ), +) { val coroutineScope = rememberCoroutineScope() val navController = rememberNavController() - val currentRoute = navController + val currentRoute by navController .currentBackStackEntryFlow .collectAsStateWithLifecycle(initialValue = navController.currentBackStackEntry) - val showUpButton = when (currentRoute.value?.destination?.route) { - BottomNavigationScreen.Home.route -> false - else -> true - } - var bottomNavSelection: Screen by remember { mutableStateOf(BottomNavigationScreen.Home) } - val bottomNavItems = listOf( - BottomNavigationScreen.Home, - BottomNavigationScreen.Resources, - BottomNavigationScreen.AboutUs, - ) - val snackbarHostState = remember { SnackbarHostState() } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val bottomNavSelection = uiState.bottomNavSelection + + // TODO: Switch to BottomNavigationScreen.entries once we are using Kotlin 1.9 + val bottomNavItems = remember { BottomNavigationScreen.values().toList().toImmutableList() } + // Show up button when not on a BottomNavigationScreen + val showUpButton = currentRoute?.destination?.route?.let { route -> + bottomNavItems.none { it.route.contains(route) } + } ?: false + val clipboardManager = LocalClipboardManager.current + LaunchedEffect(uiState.clipboardText) { + uiState.clipboardText?.let { text -> + coroutineScope.launch { + clipboardManager.setText(text) + } + } + } + SmileIDTheme { Surface { - var currentScreenTitle by remember { mutableStateOf(R.string.app_name) } Scaffold( - snackbarHost = { - SnackbarHost(snackbarHostState) { - Snackbar( - snackbarData = it, - actionColor = MaterialTheme.colorScheme.tertiary, - ) - } - }, + snackbarHost = { Snackbar() }, topBar = { - var isProduction by rememberSaveable { mutableStateOf(false) } - TopAppBar( - title = { Text(stringResource(currentScreenTitle)) }, - navigationIcon = { - if (showUpButton) { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - } - }, - actions = { - FilterChip( - selected = isProduction, - onClick = { - isProduction = !isProduction - SmileID.setEnvironment(useSandbox = !isProduction) - }, - leadingIcon = { - if (isProduction) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = stringResource( - R.string.production, - ), - ) - } - }, - label = { - val environmentName = if (isProduction) { - R.string.production - } else { - R.string.sandbox - } - Text(stringResource(environmentName)) - }, - ) - }, + TopBar( + showUpButton = showUpButton, + onNavigateUp = { navController.navigateUp() }, + isJobsScreenSelected = bottomNavSelection == BottomNavigationScreen.Jobs, ) }, bottomBar = { // Don't show bottom bar when navigating to any product screens - val currentRouteValue = currentRoute.value?.destination?.route ?: "" - if (bottomNavItems.none { it.route.contains(currentRouteValue) }) { - return@Scaffold + val showBottomBar by remember(currentRoute) { + derivedStateOf { + bottomNavItems.any { + it.route.contains( + currentRoute?.destination?.route ?: "", + ) + } + } } - NavigationBar { - bottomNavItems.forEach { - NavigationBarItem( - selected = it == bottomNavSelection, - icon = { - val imageVector = if (it == bottomNavSelection) { - it.selectedIcon - } else { - it.unselectedIcon - } - Icon(imageVector, stringResource(it.label)) - }, - label = { Text(stringResource(it.label)) }, - onClick = { - navController.navigate(it.route) { - popUpTo(BottomNavigationScreen.Home.route) - launchSingleTop = true - } - }, - ) + if (showBottomBar) { + BottomBar( + bottomNavItems = bottomNavItems, + bottomNavSelection = bottomNavSelection, + pendingJobCount = uiState.pendingJobCount, + ) { + navController.navigate(it.route) { + popUpTo(BottomNavigationScreen.Home.route) + launchSingleTop = true + } } } }, content = { NavHost( navController, - BottomNavigationScreen.Home.route, + startScreen.route, Modifier .padding(it) .consumeWindowInsets(it), ) { composable(BottomNavigationScreen.Home.route) { - bottomNavSelection = BottomNavigationScreen.Home - // Display "Smile ID" in the top bar instead of "Home" label - currentScreenTitle = R.string.app_name + LaunchedEffect(Unit) { viewModel.onHomeSelected() } ProductSelectionScreen { navController.navigate(it.route) } } + composable(BottomNavigationScreen.Jobs.route) { + LaunchedEffect(Unit) { viewModel.onJobsSelected() } + OrchestratedJobsScreen(uiState.isProduction) + } composable(BottomNavigationScreen.Resources.route) { - bottomNavSelection = BottomNavigationScreen.Resources - currentScreenTitle = BottomNavigationScreen.Resources.label + LaunchedEffect(Unit) { viewModel.onResourcesSelected() } ResourcesScreen() } composable(BottomNavigationScreen.AboutUs.route) { - bottomNavSelection = BottomNavigationScreen.AboutUs - currentScreenTitle = BottomNavigationScreen.AboutUs.label + LaunchedEffect(Unit) { viewModel.onAboutUsSelected() } AboutUsScreen() } composable(ProductScreen.SmartSelfieEnrollment.route) { - bottomNavSelection = BottomNavigationScreen.Home - currentScreenTitle = ProductScreen.SmartSelfieEnrollment.label + LaunchedEffect(Unit) { viewModel.onSmartSelfieEnrollmentSelected() } val userId = rememberSaveable { randomUserId() } + val jobId = rememberSaveable { randomJobId() } SmileID.SmartSelfieEnrollment( userId = userId, + jobId = jobId, allowAgentMode = true, showInstructions = true, ) { result -> - if (result is SmileIDResult.Success) { - val response = result.data.jobStatusResponse - val actualResult = response?.result as? JobResult.Entry - val message = jobResultMessageBuilder( - jobName = "SmartSelfie Enrollment", - jobComplete = response?.jobComplete, - jobSuccess = response?.jobSuccess, - code = response?.code, - resultCode = actualResult?.resultCode, - resultText = actualResult?.resultText, - suffix = "The User ID has been copied to your clipboard", - ) - Timber.d("$message: $result") - clipboardManager.setText(AnnotatedString(userId)) - snackbarHostState.showSnackbar(coroutineScope, message) - } else if (result is SmileIDResult.Error) { - val th = result.throwable - val message = "SmartSelfie Enrollment error: ${th.message}" - Timber.e(th, message) - snackbarHostState.showSnackbar(coroutineScope, message) - } + viewModel.onSmartSelfieEnrollmentResult(userId, jobId, result) navController.popBackStack() } } composable(ProductScreen.SmartSelfieAuthentication.route) { - bottomNavSelection = BottomNavigationScreen.Home - currentScreenTitle = ProductScreen.SmartSelfieAuthentication.label - var userId by rememberSaveable { - val clipboardText = clipboardManager.getText()?.text - // Autofill the value of User ID as it was likely just copied - mutableStateOf(clipboardText?.takeIf { "user-" in it } ?: "") - } - AlertDialog( - title = { Text(stringResource(R.string.user_id_dialog_title)) }, - text = { - OutlinedTextField( - value = userId, - onValueChange = { newValue -> userId = newValue.trim() }, - label = { Text(stringResource(R.string.user_id_label)) }, - supportingText = { - Text( - stringResource( - R.string.user_id_dialog_supporting_text, - ), - ) - }, - // This is needed to allow the dialog to grow vertically in - // case of a long User ID - modifier = Modifier.wrapContentHeight(unbounded = true), - ) + LaunchedEffect(Unit) { viewModel.onSmartSelfieAuthenticationSelected() } + SmartSelfieAuthenticationUserIdInputDialog( + onDismiss = navController::popBackStack, + onConfirm = { userId -> + navController.navigate( + "${ProductScreen.SmartSelfieAuthentication.route}/$userId", + ) { popUpTo(BottomNavigationScreen.Home.route) } }, - onDismissRequest = { navController.popBackStack() }, - dismissButton = { - OutlinedButton(onClick = { navController.popBackStack() }) { - Text(stringResource(R.string.cancel)) - } - }, - confirmButton = { - Button( - enabled = userId.isNotBlank(), - onClick = { - navController.navigate( - "${ProductScreen.SmartSelfieAuthentication.route}/$userId", // ktlint-disable max-line-length - ) { popUpTo(BottomNavigationScreen.Home.route) } - }, - ) { Text(stringResource(R.string.cont)) } - }, - // This is needed to allow the dialog to grow vertically in case of - // a long User ID - modifier = Modifier.wrapContentHeight(), ) } composable(ProductScreen.SmartSelfieAuthentication.route + "/{userId}") { - bottomNavSelection = BottomNavigationScreen.Home - currentScreenTitle = ProductScreen.SmartSelfieAuthentication.label + LaunchedEffect(Unit) { viewModel.onSmartSelfieAuthenticationSelected() } + val userId = rememberSaveable { it.arguments?.getString("userId")!! } + val jobId = rememberSaveable { randomJobId() } SmileID.SmartSelfieAuthentication( - userId = it.arguments?.getString("userId")!!, + userId = userId, + jobId = jobId, allowAgentMode = true, ) { result -> - if (result is SmileIDResult.Success) { - val response = result.data.jobStatusResponse - val actualResult = response?.result as? JobResult.Entry - val message = jobResultMessageBuilder( - jobName = "SmartSelfie Authentication", - jobComplete = response?.jobComplete, - jobSuccess = response?.jobSuccess, - code = response?.code, - resultCode = actualResult?.resultCode, - resultText = actualResult?.resultText, - ) - snackbarHostState.showSnackbar(coroutineScope, message) - Timber.d("$message: $result") - } else if (result is SmileIDResult.Error) { - val th = result.throwable - val message = "SmartSelfie Authentication error: ${th.message}" - Timber.e(th, message) - snackbarHostState.showSnackbar(coroutineScope, message) - } + viewModel.onSmartSelfieAuthenticationResult(userId, jobId, result) navController.popBackStack() } } composable(ProductScreen.EnhancedKyc.route) { - bottomNavSelection = BottomNavigationScreen.Home - currentScreenTitle = ProductScreen.EnhancedKyc.label + LaunchedEffect(Unit) { viewModel.onEnhancedKycSelected() } OrchestratedEnhancedKycScreen { result -> - if (result is SmileIDResult.Success) { - val resultData = result.data.response - val message = jobResultMessageBuilder( - jobName = "Enhanced KYC", - jobComplete = true, - jobSuccess = true, - code = null, - resultCode = resultData.resultCode, - resultText = resultData.resultText, - ) - snackbarHostState.showSnackbar(coroutineScope, message) - } else if (result is SmileIDResult.Error) { - val th = result.throwable - val message = "Enhanced KYC error: ${th.message}" - Timber.e(th, message) - snackbarHostState.showSnackbar(coroutineScope, message) - } + viewModel.onEnhancedKycResult(result) navController.popBackStack() } } composable(ProductScreen.BiometricKyc.route) { - bottomNavSelection = BottomNavigationScreen.Home - currentScreenTitle = ProductScreen.BiometricKyc.label + LaunchedEffect(Unit) { viewModel.onBiometricKycSelected() } var idInfo: IdInfo? by remember { mutableStateOf(null) } if (idInfo == null) { IdTypeSelectorAndFieldInputScreen( @@ -337,44 +219,30 @@ fun MainScreen() { onResult = { idInfo = it }, ) } - idInfo?.let { idInfo -> - val url = URL("https://usesmileid.com/privacy-policy") + idInfo?.let { + val url = remember { + URL("https://usesmileid.com/privacy-policy") + } + val userId = rememberSaveable { randomUserId() } + val jobId = rememberSaveable { randomJobId() } SmileID.BiometricKYC( - idInfo = idInfo, + idInfo = it, + userId = userId, + jobId = jobId, partnerIcon = painterResource( id = com.smileidentity.R.drawable.si_logo_with_text, ), partnerName = "Smile ID", - productName = idInfo.idType, + productName = it.idType, partnerPrivacyPolicy = url, ) { result -> - if (result is SmileIDResult.Success) { - val resultData = result.data - val actualResult = resultData.jobStatusResponse.result - as? JobResult.Entry - Timber.d("Biometric KYC Result: $result") - val message = jobResultMessageBuilder( - jobName = "Biometric KYC", - jobComplete = resultData.jobStatusResponse.jobComplete, - jobSuccess = resultData.jobStatusResponse.jobSuccess, - code = resultData.jobStatusResponse.code, - resultCode = actualResult?.resultCode, - resultText = actualResult?.resultText, - ) - snackbarHostState.showSnackbar(coroutineScope, message) - } else if (result is SmileIDResult.Error) { - val th = result.throwable - val message = "Biometric KYC error: ${th.message}" - Timber.e(th, message) - snackbarHostState.showSnackbar(coroutineScope, message) - } + viewModel.onBiometricKycResult(userId, jobId, result) navController.popBackStack() } } } composable(ProductScreen.DocumentVerification.route) { - bottomNavSelection = BottomNavigationScreen.Home - currentScreenTitle = ProductScreen.DocumentVerification.label + LaunchedEffect(Unit) { viewModel.onDocumentVerificationSelected() } DocumentVerificationIdTypeSelector { country, idType -> navController.navigate( "${ProductScreen.DocumentVerification.route}/$country/$idType", @@ -384,38 +252,22 @@ fun MainScreen() { composable( ProductScreen.DocumentVerification.route + "/{countryCode}/{idType}", ) { + LaunchedEffect(Unit) { viewModel.onDocumentVerificationSelected() } val userId = rememberSaveable { randomUserId() } val jobId = rememberSaveable { randomJobId() } - val documentType = Document( - it.arguments?.getString("countryCode")!!, - it.arguments?.getString("idType")!!, - ) + val documentType = remember(it) { + Document( + it.arguments?.getString("countryCode")!!, + it.arguments?.getString("idType")!!, + ) + } SmileID.DocumentVerification( userId = userId, jobId = jobId, idType = documentType, showInstructions = true, ) { result -> - if (result is SmileIDResult.Success) { - val resultData = result.data - val actualResult = resultData.jobStatusResponse.result - as? JobResult.Entry - val message = jobResultMessageBuilder( - jobName = "Document Verification", - jobComplete = resultData.jobStatusResponse.jobComplete, - jobSuccess = resultData.jobStatusResponse.jobSuccess, - code = resultData.jobStatusResponse.code, - resultCode = actualResult?.resultCode, - resultText = actualResult?.resultText, - ) - Timber.d("$message: $result") - snackbarHostState.showSnackbar(coroutineScope, message) - } else if (result is SmileIDResult.Error) { - val th = result.throwable - val message = "Document Verification error: ${th.message}" - Timber.e(th, message) - snackbarHostState.showSnackbar(coroutineScope, message) - } + viewModel.onDocumentVerificationResult(userId, jobId, result) navController.popBackStack( route = BottomNavigationScreen.Home.route, inclusive = false, @@ -428,3 +280,163 @@ fun MainScreen() { } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + showUpButton: Boolean, + onNavigateUp: () -> Unit, + isJobsScreenSelected: Boolean, + viewModel: MainScreenViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + TopAppBar( + title = { Text(stringResource(id = uiState.appBarTitle)) }, + navigationIcon = { + if (showUpButton) { + IconButton(onClick = onNavigateUp) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + }, + actions = { + FilterChip( + selected = uiState.isProduction, + onClick = viewModel::toggleEnvironment, + leadingIcon = { + if (uiState.isProduction) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = stringResource( + R.string.production, + ), + ) + } + }, + label = { Text(stringResource(id = uiState.environmentName)) }, + ) + if (isJobsScreenSelected) { + PlainTooltipBox( + tooltip = { + Text(stringResource(R.string.jobs_clear_jobs_icon_tooltip)) + }, + ) { + IconButton( + onClick = viewModel::clearJobs, + modifier = Modifier.tooltipAnchor(), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + } + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BottomBar( + bottomNavItems: ImmutableList, + bottomNavSelection: BottomNavigationScreen, + pendingJobCount: Int, + onBottomNavItemSelected: (BottomNavigationScreen) -> Unit, +) { + NavigationBar { + bottomNavItems.forEach { + NavigationBarItem( + selected = it == bottomNavSelection, + icon = { + BadgedBox( + badge = { + if (it == BottomNavigationScreen.Jobs && + pendingJobCount > 0 + ) { + Badge { Text(text = pendingJobCount.toString()) } + } + }, + ) { + val imageVector = if (it == bottomNavSelection) { + it.selectedIcon + } else { + it.unselectedIcon + } + Icon(imageVector, stringResource(it.label)) + } + }, + label = { Text(stringResource(it.label)) }, + onClick = { onBottomNavItemSelected(it) }, + ) + } + } +} + +@Composable +private fun SmartSelfieAuthenticationUserIdInputDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val clipboardManager = LocalClipboardManager.current + var userId by rememberSaveable { + val clipboardText = clipboardManager.getText()?.text + // Autofill the value of User ID as it was likely just copied + mutableStateOf(clipboardText?.takeIf { "user-" in it } ?: "") + } + AlertDialog( + title = { Text(stringResource(R.string.user_id_dialog_title)) }, + text = { + OutlinedTextField( + value = userId, + onValueChange = { newValue -> userId = newValue.trim() }, + label = { Text(stringResource(R.string.user_id_label)) }, + supportingText = { + Text( + stringResource( + R.string.user_id_dialog_supporting_text, + ), + ) + }, + // This is needed to allow the dialog to grow vertically in + // case of a long User ID + modifier = Modifier.wrapContentHeight(unbounded = true), + ) + }, + onDismissRequest = onDismiss, + dismissButton = { + OutlinedButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + confirmButton = { + Button( + enabled = userId.isNotBlank(), + onClick = { onConfirm(userId) }, + ) { Text(stringResource(R.string.cont)) } + }, + // This is needed to allow the dialog to grow vertically in case of + // a long User ID + modifier = Modifier.wrapContentHeight(), + ) +} + +@Composable +private fun Snackbar(viewModel: MainScreenViewModel = viewModel()) { + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val snackbarMessage = viewModel.uiState.collectAsStateWithLifecycle().value.snackbarMessage + + LaunchedEffect(snackbarMessage) { + snackbarMessage?.let { coroutineScope.launch { snackbarHostState.showSnackbar(it.value) } } + } + + SnackbarHost(snackbarHostState) { + Snackbar( + snackbarData = it, + actionColor = MaterialTheme.colorScheme.tertiary, + ) + } +} diff --git a/sample/src/main/java/com/smileidentity/sample/compose/components/ErrorScreen.kt b/sample/src/main/java/com/smileidentity/sample/compose/components/ErrorScreen.kt new file mode 100644 index 000000000..c0bf09bfe --- /dev/null +++ b/sample/src/main/java/com/smileidentity/sample/compose/components/ErrorScreen.kt @@ -0,0 +1,44 @@ +package com.smileidentity.sample.compose.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.smileidentity.sample.R +import com.smileidentity.sample.compose.SmileIDTheme + +@Composable +fun ErrorScreen( + modifier: Modifier = Modifier, + errorText: String = stringResource(id = R.string.something_went_wrong), + onRetry: () -> Unit, +) { + Column( + horizontalAlignment = CenterHorizontally, + modifier = modifier, + ) { + Icon(imageVector = Icons.Default.Warning, contentDescription = null) + Text(text = errorText) + TextButton(onClick = onRetry) { + Text(text = stringResource(id = R.string.try_again)) + } + } +} + +@Preview +@Composable +fun ErrorScreenPreview() { + SmileIDTheme { + Surface { + ErrorScreen(onRetry = {}) + } + } +} diff --git a/sample/src/main/java/com/smileidentity/sample/compose/jobs/JobsListScreen.kt b/sample/src/main/java/com/smileidentity/sample/compose/jobs/JobsListScreen.kt new file mode 100644 index 000000000..6f04c7620 --- /dev/null +++ b/sample/src/main/java/com/smileidentity/sample/compose/jobs/JobsListScreen.kt @@ -0,0 +1,256 @@ +package com.smileidentity.sample.compose.jobs + +import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.smileidentity.R +import com.smileidentity.compose.preview.SmilePreviews +import com.smileidentity.models.JobType +import com.smileidentity.models.JobType.BiometricKyc +import com.smileidentity.models.JobType.DocumentVerification +import com.smileidentity.models.JobType.EnhancedKyc +import com.smileidentity.models.JobType.SmartSelfieAuthentication +import com.smileidentity.models.JobType.SmartSelfieEnrollment +import com.smileidentity.sample.compose.SmileIDTheme +import com.smileidentity.sample.compose.components.ErrorScreen +import com.smileidentity.sample.label +import com.smileidentity.sample.model.Job +import kotlinx.collections.immutable.ImmutableList +import timber.log.Timber + +@Composable +fun JobsListScreen( + jobs: ImmutableList, + modifier: Modifier = Modifier, +) { + if (jobs.isEmpty()) { + ErrorScreen( + errorText = stringResource(com.smileidentity.sample.R.string.jobs_no_jobs_found), + onRetry = {}, + ) + return + } + LazyColumn(modifier = modifier.fillMaxSize()) { + items(jobs) { + @DrawableRes + val iconRes = when (it.jobType) { + SmartSelfieEnrollment -> R.drawable.si_smart_selfie_instructions_hero + SmartSelfieAuthentication -> R.drawable.si_smart_selfie_instructions_hero + DocumentVerification -> R.drawable.si_doc_v_instructions_hero + BiometricKyc -> com.smileidentity.sample.R.drawable.biometric_kyc + EnhancedKyc -> com.smileidentity.sample.R.drawable.enhanced_kyc + JobType.Unknown -> { + Timber.e("Unknown job type") + R.drawable.si_smart_selfie_instructions_hero + } + } + JobListItem( + sourceIcon = { + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(64.dp), + ) + }, + timestamp = it.timestamp, + jobType = stringResource(it.jobType.label), + isProcessing = !it.jobComplete, + resultText = it.resultText, + ) { + JobListItemAdditionalDetails( + userId = it.userId, + jobId = it.jobId, + smileJobId = it.smileJobId, + resultCode = it.resultCode?.toString(), + code = it.code?.toString(), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun JobListItem( + sourceIcon: @Composable () -> Unit, + timestamp: String, + jobType: String, + isProcessing: Boolean, + resultText: String?, + modifier: Modifier = Modifier, + expandedContent: @Composable ColumnScope.() -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Card( + onClick = { expanded = !expanded }, + modifier = modifier + .animateContentSize() + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + ListItem( + leadingContent = sourceIcon, + overlineContent = { Text(timestamp) }, + headlineContent = { Text(jobType) }, + supportingContent = { + if (resultText != null) { + Text(resultText, style = MaterialTheme.typography.labelLarge) + } + if (expanded) { + expandedContent() + } + if (isProcessing) { + Spacer(modifier = Modifier.size(4.dp)) + LinearProgressIndicator(strokeCap = StrokeCap.Round) + } + }, + trailingContent = { + Column( + modifier = Modifier + .fillMaxHeight() + .requiredWidthIn(max = 64.dp), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val halfCircleRotationDegrees = 180f + val animatedProgress = animateFloatAsState( + targetValue = if (expanded) halfCircleRotationDegrees else 0f, + animationSpec = spring(), + label = "Dropdown Icon Rotation", + ).value + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.background, + modifier = Modifier + .size(16.dp) + .rotate(animatedProgress) + .background( + color = MaterialTheme.colorScheme.onBackground, + shape = CircleShape, + ), + ) + } + }, + ) + } +} + +@Suppress("UnusedReceiverParameter") +@Composable +private fun ColumnScope.JobListItemAdditionalDetails( + userId: String?, + jobId: String?, + smileJobId: String?, + resultCode: String?, + code: String?, +) { + // TODO: Add Actions support + JobMetadataItem( + label = stringResource(com.smileidentity.sample.R.string.jobs_detail_user_id_label), + value = userId, + ) + JobMetadataItem( + label = stringResource(com.smileidentity.sample.R.string.jobs_detail_job_id_label), + value = jobId, + ) + JobMetadataItem( + label = stringResource(com.smileidentity.sample.R.string.jobs_detail_smile_job_id_label), + value = smileJobId, + ) + JobMetadataItem( + label = stringResource(com.smileidentity.sample.R.string.jobs_detail_result_code_label), + value = resultCode, + ) + JobMetadataItem( + label = stringResource(com.smileidentity.sample.R.string.jobs_detail_code_label), + value = code, + ) +} + +@Composable +private fun JobMetadataItem(label: String, value: String?) { + if (value != null) { + Column { + Spacer(modifier = Modifier.size(8.dp)) + Text(text = label, style = MaterialTheme.typography.labelMedium) + Spacer(modifier = Modifier.size(2.dp)) + Text(value, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +@SmilePreviews +fun JobListItemPreview() { + SmileIDTheme { + JobListItem( + sourceIcon = { + Image( + painter = painterResource( + id = R.drawable.si_smart_selfie_instructions_hero, + ), + contentDescription = null, + modifier = Modifier.size(64.dp), + ) + }, + timestamp = "7/6/23 12:04 PM", + jobType = "SmartSelfieā„¢ Enrollment", + isProcessing = true, + resultText = "Enroll User", + ) { } + } +} + +@Composable +@SmilePreviews +private fun JobListItemAdditionalDetailsPreview() { + SmileIDTheme { + Column { + JobListItemAdditionalDetails( + userId = "1234567890", + jobId = "1234567890", + smileJobId = "1234567890", + resultCode = "1234567890", + code = "1234567890", + ) + } + } +} diff --git a/sample/src/main/java/com/smileidentity/sample/compose/jobs/OrchestratedJobsScreen.kt b/sample/src/main/java/com/smileidentity/sample/compose/jobs/OrchestratedJobsScreen.kt new file mode 100644 index 000000000..5b4459667 --- /dev/null +++ b/sample/src/main/java/com/smileidentity/sample/compose/jobs/OrchestratedJobsScreen.kt @@ -0,0 +1,38 @@ +package com.smileidentity.sample.compose.jobs + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.smileidentity.compose.components.ProcessingState +import com.smileidentity.sample.compose.components.ErrorScreen +import com.smileidentity.sample.viewmodel.JobsViewModel +import com.smileidentity.viewmodel.viewModelFactory + +@Composable +fun OrchestratedJobsScreen( + isProduction: Boolean, + modifier: Modifier = Modifier, + viewModel: JobsViewModel = viewModel( + key = isProduction.toString(), + factory = viewModelFactory { JobsViewModel(isProduction) }, + ), +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val jobs = viewModel.jobs.collectAsStateWithLifecycle().value + Box( + contentAlignment = Center, + modifier = modifier + .fillMaxSize(), + ) { + when (uiState.processingState) { + ProcessingState.InProgress -> CircularProgressIndicator() + ProcessingState.Error -> ErrorScreen { /* Using a Flow, which automatically retries */ } + ProcessingState.Success -> JobsListScreen(jobs) + } + } +} diff --git a/sample/src/main/java/com/smileidentity/sample/model/Job.kt b/sample/src/main/java/com/smileidentity/sample/model/Job.kt new file mode 100644 index 000000000..c51e790d8 --- /dev/null +++ b/sample/src/main/java/com/smileidentity/sample/model/Job.kt @@ -0,0 +1,115 @@ +package com.smileidentity.sample.model + +import com.smileidentity.models.BiometricKycJobStatusResponse +import com.smileidentity.models.DocVJobStatusResponse +import com.smileidentity.models.EnhancedKycResponse +import com.smileidentity.models.JobResult +import com.smileidentity.models.JobStatusResponse +import com.smileidentity.models.JobType +import com.smileidentity.models.JobType.BiometricKyc +import com.smileidentity.models.JobType.DocumentVerification +import com.smileidentity.models.JobType.EnhancedKyc +import com.smileidentity.models.JobType.SmartSelfieAuthentication +import com.smileidentity.models.JobType.SmartSelfieEnrollment +import com.smileidentity.models.SmartSelfieJobStatusResponse +import com.smileidentity.sample.repo.DataStoreRepository +import com.squareup.moshi.JsonClass +import timber.log.Timber +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * This class is used by the [DataStoreRepository] to store Preferences/to be shown on the Jobs + * screen. It is saved to Preferences by saving as JSON String using Moshi. As a result, be very + * careful about breaking changes to the JSON schema! + */ +@JsonClass(generateAdapter = true) +data class Job( + val jobType: JobType, + val timestamp: String, + val userId: String, + val jobId: String, + val jobComplete: Boolean, + val jobSuccess: Boolean, + val code: Int?, + val resultCode: Int?, + val smileJobId: String?, + val resultText: String?, + val selfieImageUrl: String?, +) + +private val outputFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) +private val inputFormat = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + +/** + * Converts "2023-07-10T21:58:07.183Z" to "7/10/23, 2:58 PM" (assuming PST timezone) + */ +private fun toHumanReadableTimestamp(timestamp: String): String { + return try { + val date = inputFormat.parse(timestamp) + outputFormat.format(date) + } catch (e: Exception) { + Timber.e(e, "Failed to parse timestamp: $timestamp") + timestamp + } +} + +fun EnhancedKycResponse.toJob() = Job( + jobType = EnhancedKyc, + // Enhanced KYC is a synchronous response + timestamp = toHumanReadableTimestamp(inputFormat.format(Date())), + userId = partnerParams.userId, + jobId = partnerParams.jobId, + jobComplete = true, + jobSuccess = true, + code = null, + resultCode = resultCode, + smileJobId = smileJobId, + resultText = resultText, + // Enhanced KYC *does* return a Base 64 encoded selfie image, but we are not using it here + // due to performance concerns w.r.t. disk I/O + selfieImageUrl = null, +) + +fun SmartSelfieJobStatusResponse.toJob(userId: String, jobId: String, isEnrollment: Boolean) = + toJob( + userId = userId, + jobId = jobId, + jobType = if (isEnrollment) SmartSelfieEnrollment else SmartSelfieAuthentication, + ) + +fun BiometricKycJobStatusResponse.toJob(userId: String, jobId: String) = toJob( + userId = userId, + jobId = jobId, + jobType = BiometricKyc, +) + +fun DocVJobStatusResponse.toJob(userId: String, jobId: String) = toJob( + userId = userId, + jobId = jobId, + jobType = DocumentVerification, +) + +fun JobStatusResponse.toJob( + userId: String, + jobId: String, + jobType: JobType, +) = Job( + jobType = jobType, + timestamp = toHumanReadableTimestamp(timestamp), + userId = userId, + jobId = jobId, + jobComplete = jobComplete, + jobSuccess = jobSuccess, + code = code, + resultCode = (result as? JobResult.Entry)?.resultCode, + smileJobId = (result as? JobResult.Entry)?.smileJobId, + resultText = (result as? JobResult.Entry)?.resultText, + selfieImageUrl = imageLinks?.selfieImageUrl, +) diff --git a/sample/src/main/java/com/smileidentity/sample/repo/DataStoreRepository.kt b/sample/src/main/java/com/smileidentity/sample/repo/DataStoreRepository.kt index bfce074c6..79370b43f 100644 --- a/sample/src/main/java/com/smileidentity/sample/repo/DataStoreRepository.kt +++ b/sample/src/main/java/com/smileidentity/sample/repo/DataStoreRepository.kt @@ -1,14 +1,27 @@ package com.smileidentity.sample.repo +import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import com.smileidentity.SmileID import com.smileidentity.models.Config import com.smileidentity.sample.SmileIDApplication +import com.smileidentity.sample.model.Job +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +private typealias PartnerId = String +private typealias IsProduction = Boolean +private typealias JobsDataStoreMapKey = Pair /** * Singleton wrapper to allow for typed usage of [androidx.datastore.core.DataStore]. @@ -17,24 +30,53 @@ object DataStoreRepository { /** * The main [androidx.datastore.core.DataStore] instance for the app, aptly named "main". */ - private val dataStore = PreferenceDataStoreFactory.create { - SmileIDApplication.appContext.preferencesDataStoreFile("main") + private val mainDataStore = PreferenceDataStoreFactory.create { + SmileIDApplication.appContext.preferencesDataStoreFile(name = "main") } + /** + * Each partner+environment combination gets its own [DataStore] instance, so that we can + * update the status of a single job without having to read and write the entire set of jobs. + */ + private val jobsDataStores = with(mutableMapOf>()) { + withDefault { + getOrPut(it) { + Timber.v("Initializing DataStore for partnerId=${it.first} production=${it.second}") + PreferenceDataStoreFactory.create { + val name = "jobs_${it.first}_${it.second}" + SmileIDApplication.appContext.preferencesDataStoreFile(name = name) + } + } + } + } + + private val jobsAdapter = SmileID.moshi.adapter(Job::class.java) + + /** + * To provide atomicity guarantees for situations where an update first requires an independent + * read of the data, modifying it, and then writing it back. (i.e. inserting a single value into + * an existing set). We use [Mutex] because the alternative, [synchronized], blocks the *entire* + * thread, and multiple coroutines could be running on the same thread. + * + * Theoretically, for best performance, we should use a mutex per [DataStore] instance, but + * concurrent transactions across different [DataStore] instances are extremely unlikely + */ + private val mutex = Mutex() + /** * Caution! Deletes all preferences */ internal suspend fun clear() { - dataStore.edit { it.clear() } + mainDataStore.edit { it.clear() } } /** - * Get the Smile [Config] from [dataStore], if one has been manually set. (It may not be set if + * Get the Smile [Config] from [mainDataStore], if one has been manually set. (It may not be set if * the sample app is utilizing the assets/smile_config.json file instead). * * If no config is set, this returns null */ - fun getConfig(): Flow = dataStore.data.map { + fun getConfig(): Flow = mainDataStore.data.map { it[Keys.config]?.let { SmileID.moshi.adapter(Config::class.java).fromJson(it) } } @@ -42,7 +84,7 @@ object DataStoreRepository { * Save the Smile [Config] which was set at runtime */ suspend fun setConfig(config: Config) { - dataStore.edit { + mainDataStore.edit { it[Keys.config] = SmileID.moshi.adapter(Config::class.java).toJson(config) } } @@ -52,15 +94,96 @@ object DataStoreRepository { * assets) */ suspend fun clearConfig() { - dataStore.edit { - it.remove(Keys.config) + mainDataStore.edit { it.remove(Keys.config) } + } + + fun getPendingJobs(partnerId: String, isProduction: Boolean) = + jobDataStore(partnerId, isProduction).data.map { + val pendingJobs = it[Keys.pendingJobs] ?: emptySet() + pendingJobs.mapNotNull { + jobsAdapter.fromJson(it) ?: run { + Timber.e("Failed to parse pending job: $it") + null + } + }.sortedByDescending(Job::timestamp).toImmutableList() } + + suspend fun addPendingJob(partnerId: String, isProduction: Boolean, job: Job) { + val dataStore = jobDataStore(partnerId, isProduction) + mutex.withLock { + val pendingJobs = dataStore.data.first()[Keys.pendingJobs] ?: emptySet() + dataStore.edit { it[Keys.pendingJobs] = pendingJobs + jobsAdapter.toJson(job) } + } + } + + suspend fun addCompletedJob(partnerId: String, isProduction: Boolean, job: Job) { + val dataStore = jobDataStore(partnerId, isProduction) + mutex.withLock { + val completedJobs = dataStore.data.first()[Keys.completedJobs] ?: emptySet() + dataStore.edit { it[Keys.completedJobs] = completedJobs + jobsAdapter.toJson(job) } + } + } + + /** + * Remove a job from [Keys.pendingJobs] and add it to [Keys.completedJobs] set. + * + * Pre-condition: [completedJob] should actually be completed (either successfully or errored) + */ + suspend fun markPendingJobAsCompleted( + partnerId: String, + isProduction: Boolean, + completedJob: Job, + ) { + val dataStore = jobDataStore(partnerId, isProduction) + mutex.withLock { + val pendingJobs = dataStore.data.first()[Keys.pendingJobs] ?: emptySet() + val completedJobs = dataStore.data.first()[Keys.completedJobs] ?: emptySet() + val pendingJob = pendingJobs.firstOrNull() { it.contains(completedJob.jobId) } + dataStore.edit { + if (pendingJob != null) { + it[Keys.pendingJobs] = pendingJobs - pendingJob + } + it[Keys.completedJobs] = completedJobs + jobsAdapter.toJson(completedJob) + } + } + } + + fun getAllJobs(partnerId: String, isProduction: Boolean) = + jobDataStore(partnerId, isProduction).data.map { + val pendingJobs = it[Keys.pendingJobs] ?: emptySet() + val completedJobs = it[Keys.completedJobs] ?: emptySet() + val allJobs = pendingJobs + completedJobs + allJobs.mapNotNull { + jobsAdapter.fromJson(it) ?: run { + Timber.e("Failed to parse pending job: $it") + null + } + }.sortedByDescending(Job::timestamp).toImmutableList() + } + + suspend fun clearJobs(partnerId: String, isProduction: Boolean) { + val dataStore = jobDataStore(partnerId, isProduction) + mutex.withLock { + dataStore.edit { + it.remove(Keys.pendingJobs) + it.remove(Keys.completedJobs) + } + } + } + + /** + * The [DataStore] instance for the given partner+environment combination + */ + private fun jobDataStore(partnerId: String, isProduction: Boolean): DataStore { + return jobsDataStores.getValue(partnerId to isProduction) } /** - * The set of keys to be used with [dataStore]. + * The set of keys to be used with [mainDataStore]. */ private object Keys { val config = stringPreferencesKey("config") + val pendingJobs = stringSetPreferencesKey("pendingJobs") + val completedJobs = stringSetPreferencesKey("completedJobs") } } diff --git a/sample/src/main/java/com/smileidentity/sample/viewmodel/JobsViewModel.kt b/sample/src/main/java/com/smileidentity/sample/viewmodel/JobsViewModel.kt new file mode 100644 index 000000000..dba76d9ea --- /dev/null +++ b/sample/src/main/java/com/smileidentity/sample/viewmodel/JobsViewModel.kt @@ -0,0 +1,39 @@ +package com.smileidentity.sample.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smileidentity.SmileID +import com.smileidentity.compose.components.ProcessingState +import com.smileidentity.sample.repo.DataStoreRepository.getAllJobs +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import timber.log.Timber + +data class JobsUiState( + val processingState: ProcessingState = ProcessingState.InProgress, + val errorMessage: String? = null, +) + +/** + * The job list to show is determined by a composite key of partnerId and environment + */ +class JobsViewModel(isProduction: Boolean) : ViewModel() { + private val _uiState = MutableStateFlow(JobsUiState()) + val uiState = _uiState.asStateFlow() + val jobs = getAllJobs(SmileID.config.partnerId, isProduction).catch { + Timber.e(it) + _uiState.update { it.copy(processingState = ProcessingState.Error) } + }.onEach { + _uiState.update { it.copy(processingState = ProcessingState.Success) } + }.stateIn( + scope = viewModelScope, + started = WhileSubscribed(), + initialValue = persistentListOf(), + ) +} diff --git a/sample/src/main/java/com/smileidentity/sample/viewmodel/MainScreenViewModel.kt b/sample/src/main/java/com/smileidentity/sample/viewmodel/MainScreenViewModel.kt new file mode 100644 index 000000000..1993b8081 --- /dev/null +++ b/sample/src/main/java/com/smileidentity/sample/viewmodel/MainScreenViewModel.kt @@ -0,0 +1,438 @@ +package com.smileidentity.sample.viewmodel + +import androidx.annotation.StringRes +import androidx.compose.ui.text.AnnotatedString +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smileidentity.SmileID +import com.smileidentity.models.AuthenticationRequest +import com.smileidentity.models.JobResult +import com.smileidentity.models.JobStatusRequest +import com.smileidentity.models.JobType.BiometricKyc +import com.smileidentity.models.JobType.DocumentVerification +import com.smileidentity.models.JobType.SmartSelfieAuthentication +import com.smileidentity.models.JobType.SmartSelfieEnrollment +import com.smileidentity.networking.pollBiometricKycJobStatus +import com.smileidentity.networking.pollDocVJobStatus +import com.smileidentity.networking.pollSmartSelfieJobStatus +import com.smileidentity.results.BiometricKycResult +import com.smileidentity.results.DocumentVerificationResult +import com.smileidentity.results.EnhancedKycResult +import com.smileidentity.results.SmartSelfieResult +import com.smileidentity.results.SmileIDResult +import com.smileidentity.sample.BottomNavigationScreen +import com.smileidentity.sample.ProductScreen +import com.smileidentity.sample.R +import com.smileidentity.sample.jobResultMessageBuilder +import com.smileidentity.sample.model.toJob +import com.smileidentity.sample.repo.DataStoreRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Wrapper for the message, so that LaunchedEffect's key changes even if we need to display the same + * message again. We *can't* use data class because the generated equals would be the same for the + * same String + */ +class SnackbarMessage(val value: String) + +data class MainScreenUiState( + @StringRes val appBarTitle: Int = R.string.app_name, + val isProduction: Boolean = !SmileID.useSandbox, + val snackbarMessage: SnackbarMessage? = null, + val bottomNavSelection: BottomNavigationScreen = startScreen, + val pendingJobCount: Int = 0, + val clipboardText: AnnotatedString? = null, +) { + @StringRes + val environmentName = if (isProduction) R.string.production else R.string.sandbox + + companion object { + val startScreen = BottomNavigationScreen.Home + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class MainScreenViewModel : ViewModel() { + private val _uiState = MutableStateFlow(MainScreenUiState()) + val uiState = _uiState.asStateFlow() + + private var pendingJobCountJob = createPendingJobCountPoller() + private var backgroundJobsPollingJob = createBackgroundJobsPoller() + + private fun createBackgroundJobsPoller() = viewModelScope.launch { + val authRequest = AuthenticationRequest(SmartSelfieEnrollment) + val authResponse = SmileID.api.authenticate(authRequest) + DataStoreRepository.getPendingJobs(SmileID.config.partnerId, !SmileID.useSandbox) + .distinctUntilChanged() + .flatMapMerge { it.asFlow() } + .collect { job -> + val userId = job.userId + val jobId = job.jobId + val jobType = job.jobType + val request = JobStatusRequest( + userId = userId, + jobId = jobId, + includeImageLinks = false, + includeHistory = false, + partnerId = SmileID.config.partnerId, + timestamp = authResponse.timestamp, + signature = authResponse.signature, + ) + val pollFlow = when (jobType) { + SmartSelfieAuthentication -> SmileID.api.pollSmartSelfieJobStatus(request) + SmartSelfieEnrollment -> SmileID.api.pollSmartSelfieJobStatus(request) + DocumentVerification -> SmileID.api.pollDocVJobStatus(request) + BiometricKyc -> SmileID.api.pollBiometricKycJobStatus(request) + else -> { + Timber.e("Unexpected pending job: $job") + throw IllegalStateException("Unexpected pending job: $job") + } + } + .map { it.toJob(userId, jobId, jobType) } + .catch { + Timber.e(it, "Job polling failed") + DataStoreRepository.markPendingJobAsCompleted( + partnerId = SmileID.config.partnerId, + isProduction = !SmileID.useSandbox, + completedJob = job.copy( + jobComplete = true, + resultText = "Job polling error", + ), + ) + } + + // launch, instead of immediately collecting, so that we don't block the flow + launch { + // We only care about the last value - either the job completed or timed out + // NB! We will *not* update the state once the job has locally timed out + pollFlow.lastOrNull()?.let { + if (it.jobComplete) { + DataStoreRepository.markPendingJobAsCompleted( + partnerId = SmileID.config.partnerId, + isProduction = !SmileID.useSandbox, + completedJob = it, + ) + _uiState.update { + it.copy(snackbarMessage = SnackbarMessage("Job Completed")) + } + } else { + DataStoreRepository.markPendingJobAsCompleted( + partnerId = SmileID.config.partnerId, + isProduction = !SmileID.useSandbox, + completedJob = it.copy( + jobComplete = true, + resultText = "Job polling timed out", + ), + ) + _uiState.update { + it.copy( + snackbarMessage = SnackbarMessage("Job Polling Timed Out"), + ) + } + } + } + } + } + } + + private fun createPendingJobCountPoller() = viewModelScope.launch { + DataStoreRepository.getPendingJobs(SmileID.config.partnerId, !SmileID.useSandbox) + .distinctUntilChanged() + .map { it.size } + .collect { count -> + _uiState.update { it.copy(pendingJobCount = count) } + } + } + + /** + * Cancel any polling, switch the environment, and then restart polling (the auth request + * will automatically pick up the correct environment) + */ + fun toggleEnvironment() { + pendingJobCountJob.cancel() + backgroundJobsPollingJob.cancel() + + SmileID.setEnvironment(!SmileID.useSandbox) + + pendingJobCountJob = createPendingJobCountPoller() + backgroundJobsPollingJob = createBackgroundJobsPoller() + + _uiState.update { it.copy(isProduction = !SmileID.useSandbox) } + } + + fun onHomeSelected() { + _uiState.update { + it.copy( + appBarTitle = R.string.app_name, + bottomNavSelection = BottomNavigationScreen.Home, + ) + } + } + + fun onJobsSelected() { + _uiState.update { + it.copy( + appBarTitle = BottomNavigationScreen.Jobs.label, + bottomNavSelection = BottomNavigationScreen.Jobs, + ) + } + } + + fun onResourcesSelected() { + _uiState.update { + it.copy( + appBarTitle = BottomNavigationScreen.Resources.label, + bottomNavSelection = BottomNavigationScreen.Resources, + ) + } + } + + fun onAboutUsSelected() { + _uiState.update { + it.copy( + appBarTitle = BottomNavigationScreen.AboutUs.label, + bottomNavSelection = BottomNavigationScreen.AboutUs, + ) + } + } + + fun onSmartSelfieEnrollmentSelected() { + _uiState.update { + it.copy( + appBarTitle = ProductScreen.SmartSelfieEnrollment.label, + bottomNavSelection = BottomNavigationScreen.Home, + ) + } + } + + fun onSmartSelfieEnrollmentResult( + userId: String, + jobId: String, + result: SmileIDResult, + ) { + if (result is SmileIDResult.Success) { + val response = result.data.jobStatusResponse ?: run { + val errorMessage = "SmartSelfie Enrollment jobStatusResponse is null" + Timber.e(errorMessage) + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(errorMessage)) } + return + } + val actualResult = response.result as? JobResult.Entry + val message = jobResultMessageBuilder( + jobName = "SmartSelfie Enrollment", + jobComplete = response.jobComplete, + jobSuccess = response.jobSuccess, + code = response.code, + resultCode = actualResult?.resultCode, + resultText = actualResult?.resultText, + suffix = "The User ID has been copied to your clipboard", + ) + Timber.d("$message: $result") + _uiState.update { + it.copy( + clipboardText = AnnotatedString(userId), + snackbarMessage = SnackbarMessage(message), + ) + } + viewModelScope.launch { + DataStoreRepository.addPendingJob( + partnerId = SmileID.config.partnerId, + isProduction = uiState.value.isProduction, + job = response.toJob(userId, jobId, true), + ) + } + } else if (result is SmileIDResult.Error) { + val th = result.throwable + val message = "SmartSelfie Enrollment error: ${th.message}" + Timber.e(th, message) + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + } + } + + fun onSmartSelfieAuthenticationSelected() { + _uiState.update { + it.copy( + appBarTitle = ProductScreen.SmartSelfieAuthentication.label, + bottomNavSelection = BottomNavigationScreen.Home, + ) + } + } + + fun onSmartSelfieAuthenticationResult( + userId: String, + jobId: String, + result: SmileIDResult, + ) { + if (result is SmileIDResult.Success) { + val response = result.data.jobStatusResponse ?: run { + val errorMessage = "SmartSelfie Authentication jobStatusResponse is null" + Timber.e(errorMessage) + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(errorMessage)) } + return + } + val actualResult = response.result as? JobResult.Entry + val message = jobResultMessageBuilder( + jobName = "SmartSelfie Authentication", + jobComplete = response.jobComplete, + jobSuccess = response.jobSuccess, + code = response.code, + resultCode = actualResult?.resultCode, + resultText = actualResult?.resultText, + ) + Timber.d("$message: $result") + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + viewModelScope.launch { + DataStoreRepository.addPendingJob( + partnerId = SmileID.config.partnerId, + isProduction = uiState.value.isProduction, + job = response.toJob(userId, jobId, true), + ) + } + } else if (result is SmileIDResult.Error) { + val th = result.throwable + val message = "SmartSelfie Authentication error: ${th.message}" + Timber.e(th, message) + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + } + } + + fun onEnhancedKycSelected() { + _uiState.update { + it.copy( + appBarTitle = ProductScreen.EnhancedKyc.label, + bottomNavSelection = BottomNavigationScreen.Home, + ) + } + } + + fun onEnhancedKycResult(result: SmileIDResult) { + if (result is SmileIDResult.Success) { + val resultData = result.data.response + val message = jobResultMessageBuilder( + jobName = "Enhanced KYC", + jobComplete = true, + jobSuccess = true, + code = null, + resultCode = resultData.resultCode, + resultText = resultData.resultText, + ) + Timber.d("$message: $result") + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + viewModelScope.launch { + // Enhanced KYC completes synchronously + DataStoreRepository.addCompletedJob( + partnerId = SmileID.config.partnerId, + isProduction = uiState.value.isProduction, + job = resultData.toJob(), + ) + } + } else if (result is SmileIDResult.Error) { + val th = result.throwable + val message = "Enhanced KYC error: ${th.message}" + Timber.e(th, message) + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + } + } + + fun onBiometricKycSelected() { + _uiState.update { + it.copy( + appBarTitle = ProductScreen.BiometricKyc.label, + bottomNavSelection = BottomNavigationScreen.Home, + ) + } + } + + fun onBiometricKycResult( + userId: String, + jobId: String, + result: SmileIDResult, + ) { + if (result is SmileIDResult.Success) { + val response = result.data.jobStatusResponse + val actualResult = response.result as? JobResult.Entry + Timber.d("Biometric KYC Result: $result") + val message = jobResultMessageBuilder( + jobName = "Biometric KYC", + jobComplete = response.jobComplete, + jobSuccess = response.jobSuccess, + code = response.code, + resultCode = actualResult?.resultCode, + resultText = actualResult?.resultText, + ) + Timber.d("$message: $result") + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + viewModelScope.launch { + DataStoreRepository.addPendingJob( + partnerId = SmileID.config.partnerId, + isProduction = uiState.value.isProduction, + job = response.toJob(userId, jobId), + ) + } + } else if (result is SmileIDResult.Error) { + val th = result.throwable + val message = "Biometric KYC error: ${th.message}" + Timber.e(th, message) + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + } + } + + fun onDocumentVerificationSelected() { + _uiState.update { + it.copy( + appBarTitle = ProductScreen.DocumentVerification.label, + bottomNavSelection = BottomNavigationScreen.Home, + ) + } + } + + fun onDocumentVerificationResult( + userId: String, + jobId: String, + result: SmileIDResult, + ) { + if (result is SmileIDResult.Success) { + val response = result.data.jobStatusResponse + val actualResult = response.result as? JobResult.Entry + val message = jobResultMessageBuilder( + jobName = "Document Verification", + jobComplete = response.jobComplete, + jobSuccess = response.jobSuccess, + code = response.code, + resultCode = actualResult?.resultCode, + resultText = actualResult?.resultText, + ) + Timber.d("$message: $result") + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + viewModelScope.launch { + DataStoreRepository.addPendingJob( + partnerId = SmileID.config.partnerId, + isProduction = uiState.value.isProduction, + job = response.toJob(userId, jobId), + ) + } + } else if (result is SmileIDResult.Error) { + val th = result.throwable + val message = "Document Verification error: ${th.message}" + Timber.e(th, message) + _uiState.update { it.copy(snackbarMessage = SnackbarMessage(message)) } + } + } + + fun clearJobs() { + viewModelScope.launch { + DataStoreRepository.clearJobs(SmileID.config.partnerId, !SmileID.useSandbox) + } + } +} diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index fc05e65f9..dd33fe15a 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -11,11 +11,18 @@ Continue Loadingā€¦ Partner %1$s ā€¢ %2$s + Try Again + Something went wrong - - User ID - Enter User ID - From a previous successful SmartSelfieā„¢ Enrollment + + Jobs + Clear Job History + No jobs found + User ID + Job ID + Smile Job ID + Result Code + Code Resources @@ -35,6 +42,11 @@ Visit our website Contact support + + User ID + Enter User ID + From a previous successful SmartSelfieā„¢ Enrollment + Enhanced KYC Provide ID Information