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