diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9d083..7e8ec46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Release Notes +## 10.1.12 +* Added selfie capture screens +* Added document capture screens +* Bump android to 10.3.1 (https://github.com/smileidentity/android/releases/tag/v10.3.1) +* Bump iOS to 10.2.12 (https://github.com/smileidentity/ios/releases/tag/v10.2.12) + ## 10.1.11 * Fix config issues on iOS diff --git a/android/build.gradle b/android/build.gradle index 7c4dac7..0957b0b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -116,17 +116,17 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core" implementation "com.smileidentity:android-sdk:$smile_id_sdk_version" + implementation "com.google.mlkit:object-detection:17.0.2" implementation "com.jakewharton.timber:timber" implementation 'androidx.appcompat:appcompat:1.7.0' - implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.navigation:navigation-compose:2.8.1") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6") testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' - androidTestImplementation platform('androidx.compose:compose-bom:2024.06.00') - androidTestImplementation 'androidx.compose.ui:ui-test-junit4' - androidTestImplementation platform('androidx.compose:compose-bom:2024.06.00') + androidTestImplementation platform('androidx.compose:compose-bom:2024.09.02') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' diff --git a/android/gradle.properties b/android/gradle.properties index d8bf333..615338c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -3,4 +3,4 @@ SmileId_minSdkVersion=21 SmileId_targetSdkVersion=34 SmileId_compileSdkVersion=34 SmileId_ndkversion=21.4.7075529 -SmileId_androidVersion=10.2.5 +SmileId_androidVersion=10.3.1 diff --git a/android/src/main/java/com/smileidentity/react/SmileIdPackage.kt b/android/src/main/java/com/smileidentity/react/SmileIdPackage.kt index 59dd292..3f06481 100644 --- a/android/src/main/java/com/smileidentity/react/SmileIdPackage.kt +++ b/android/src/main/java/com/smileidentity/react/SmileIdPackage.kt @@ -8,15 +8,20 @@ import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager import com.smileidentity.react.viewmanagers.SmileIDBiometricKYCViewManager import com.smileidentity.react.viewmanagers.SmileIDConsentViewManager +import com.smileidentity.react.viewmanagers.SmileIDDocumentCaptureViewManager import com.smileidentity.react.viewmanagers.SmileIDDocumentVerificationViewManager import com.smileidentity.react.viewmanagers.SmileIDEnhancedDocumentVerificationViewManager import com.smileidentity.react.viewmanagers.SmileIDSmartSelfieAuthenticationViewManager +import com.smileidentity.react.viewmanagers.SmileIDSmartSelfieCaptureViewManager import com.smileidentity.react.viewmanagers.SmileIDSmartSelfieEnrollmentViewManager +import com.smileidentity.react.views.SmileIDDocumentCaptureView class SmileIdPackage : TurboReactPackage() { override fun createViewManagers(reactContext: ReactApplicationContext): List> = listOf( + SmileIDSmartSelfieCaptureViewManager(reactContext), + SmileIDDocumentCaptureViewManager(reactContext), SmileIDSmartSelfieEnrollmentViewManager(reactContext), SmileIDSmartSelfieAuthenticationViewManager(reactContext), SmileIDDocumentVerificationViewManager(reactContext), diff --git a/android/src/main/java/com/smileidentity/react/utils/DocumentCaptureResultAdapter.kt b/android/src/main/java/com/smileidentity/react/utils/DocumentCaptureResultAdapter.kt new file mode 100644 index 0000000..445b6db --- /dev/null +++ b/android/src/main/java/com/smileidentity/react/utils/DocumentCaptureResultAdapter.kt @@ -0,0 +1,54 @@ +package com.smileidentity.react.utils + +import com.smileidentity.react.views.DocumentCaptureResult +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.ToJson +import java.io.File +import java.lang.reflect.Type + +class DocumentCaptureResultAdapter : JsonAdapter() { + + @FromJson + override fun fromJson(reader: JsonReader): DocumentCaptureResult { + reader.beginObject() + var frontFile: File? = null + var backFile: File? = null + while (reader.hasNext()) { + when (reader.nextName()) { + "documentFrontFile" -> frontFile = reader.nextString()?.let { File(it) } + "documentBackFile" -> backFile = reader.nextString()?.let { File(it) } + else -> reader.skipValue() + } + } + reader.endObject() + return DocumentCaptureResult(frontFile, backFile) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: DocumentCaptureResult?) { + if (value == null) { + writer.nullValue() + return + } + writer.beginObject() + writer.name("documentFrontFile").value(value.documentFrontFile?.absolutePath) + writer.name("documentBackFile").value(value.documentBackFile?.absolutePath) + writer.endObject() + } + + companion object { + val FACTORY = object : Factory { + override fun create( + type: Type, + annotations: Set, + moshi: Moshi + ): JsonAdapter<*>? { + return if (type == DocumentCaptureResult::class.java) DocumentCaptureResultAdapter() else null + } + } + } +} diff --git a/android/src/main/java/com/smileidentity/react/utils/SelfieCaptureResultAdapter.kt b/android/src/main/java/com/smileidentity/react/utils/SelfieCaptureResultAdapter.kt new file mode 100644 index 0000000..eab7b5f --- /dev/null +++ b/android/src/main/java/com/smileidentity/react/utils/SelfieCaptureResultAdapter.kt @@ -0,0 +1,67 @@ +package com.smileidentity.react.utils + +import com.smileidentity.react.views.SmartSelfieCaptureResult +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.ToJson +import java.io.File +import java.lang.reflect.Type + +class SelfieCaptureResultAdapter : JsonAdapter() { + + @FromJson + override fun fromJson(reader: JsonReader): SmartSelfieCaptureResult { + reader.beginObject() + var selfieFile: File? = null + var livenessFiles: List? = null + while (reader.hasNext()) { + when (reader.nextName()) { + "selfieFile" -> selfieFile = reader.nextString()?.let { File(it) } + "livenessFiles" -> { + reader.beginArray() + val files = mutableListOf() + while (reader.hasNext()) { + reader.nextString()?.let { files.add(File(it)) } + } + reader.endArray() + livenessFiles = files + } + else -> reader.skipValue() + } + } + reader.endObject() + return SmartSelfieCaptureResult(selfieFile, livenessFiles) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: SmartSelfieCaptureResult?) { + if (value == null) { + writer.nullValue() + return + } + writer.beginObject() + writer.name("selfieFile").value(value.selfieFile?.absolutePath) + writer.name("livenessFiles") + writer.beginArray() + value.livenessFiles?.forEach { file -> + writer.value(file.absolutePath) + } + writer.endArray() + writer.endObject() + } + + companion object { + val FACTORY = object : Factory { + override fun create( + type: Type, + annotations: Set, + moshi: Moshi + ): JsonAdapter<*>? { + return if (type == SmartSelfieCaptureResult::class.java) SelfieCaptureResultAdapter() else null + } + } + } +} diff --git a/android/src/main/java/com/smileidentity/react/viewmanagers/SmileIDDocumentCaptureViewManager.kt b/android/src/main/java/com/smileidentity/react/viewmanagers/SmileIDDocumentCaptureViewManager.kt new file mode 100644 index 0000000..e258607 --- /dev/null +++ b/android/src/main/java/com/smileidentity/react/viewmanagers/SmileIDDocumentCaptureViewManager.kt @@ -0,0 +1,65 @@ +package com.smileidentity.react.viewmanagers + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.smileidentity.react.utils.getBoolOrDefault +import com.smileidentity.react.utils.getStringOrDefault +import com.smileidentity.react.views.SmileIDDocumentCaptureView + +@ReactModule(name = SmileIDDocumentCaptureViewManager.NAME) +class SmileIDDocumentCaptureViewManager( + private val reactApplicationContext: ReactApplicationContext +) : SimpleViewManager() { + override fun getName(): String = NAME + + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + "onSmileResult" to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to "onResult" + ) + ) + ) + } + + override fun getCommandsMap(): Map { + return mapOf("setParams" to COMMAND_SET_PARAMS) + } + + override fun receiveCommand( + view: SmileIDDocumentCaptureView, + commandId: String?, + args: ReadableArray? + ) { + super.receiveCommand(view, commandId, args) + when (commandId?.toInt()) { + COMMAND_SET_PARAMS -> { + // Extract params from args and apply to view + val params = args?.getMap(0) + params?.let { + view.userId = params.getStringOrDefault("userId") + view.jobId = params.getStringOrDefault("jobId") + view.allowAgentMode = params.getBoolOrDefault("allowAgentMode", true) + view.showAttribution = params.getBoolOrDefault("showAttribution", true) + view.showInstructions = params.getBoolOrDefault("showInstructions", true) + view.showConfirmation = params.getBoolOrDefault("showConfirmation", true) + view.allowGalleryUpload = params.getBoolOrDefault("allowGalleryUpload", false) + view.front = params.getBoolOrDefault("isDocumentFrontSide", true) + view.renderContent() + } + } + } + } + + override fun createViewInstance(p0: ThemedReactContext): SmileIDDocumentCaptureView { + return SmileIDDocumentCaptureView(reactApplicationContext) + } + + companion object { + const val NAME = "SmileIDDocumentCaptureView" + const val COMMAND_SET_PARAMS = 1 + } +} diff --git a/android/src/main/java/com/smileidentity/react/viewmanagers/SmileIDSmartSelfieCaptureViewManager.kt b/android/src/main/java/com/smileidentity/react/viewmanagers/SmileIDSmartSelfieCaptureViewManager.kt new file mode 100644 index 0000000..c40dc99 --- /dev/null +++ b/android/src/main/java/com/smileidentity/react/viewmanagers/SmileIDSmartSelfieCaptureViewManager.kt @@ -0,0 +1,63 @@ +package com.smileidentity.react.viewmanagers + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.smileidentity.react.utils.getBoolOrDefault +import com.smileidentity.react.utils.getStringOrDefault +import com.smileidentity.react.views.SmileIDSmartSelfieCaptureView + +@ReactModule(name = SmileIDSmartSelfieCaptureViewManager.NAME) +class SmileIDSmartSelfieCaptureViewManager( + private val reactApplicationContext: ReactApplicationContext +) : SimpleViewManager() { + override fun getName(): String = NAME + + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return mapOf( + "onSmileResult" to mapOf( + "phasedRegistrationNames" to mapOf( + "bubbled" to "onResult" + ) + ) + ) + } + + override fun getCommandsMap(): Map { + return mapOf("setParams" to COMMAND_SET_PARAMS) + } + + override fun receiveCommand( + view: SmileIDSmartSelfieCaptureView, + commandId: String?, + args: ReadableArray? + ) { + super.receiveCommand(view, commandId, args) + when (commandId?.toInt()) { + COMMAND_SET_PARAMS -> { + // Extract params from args and apply to view + val params = args?.getMap(0) + params?.let { + view.userId = params.getStringOrDefault("userId") + view.jobId = params.getStringOrDefault("jobId") + view.allowAgentMode = params.getBoolOrDefault("allowAgentMode", false) + view.showAttribution = params.getBoolOrDefault("showAttribution", true) + view.showInstructions = params.getBoolOrDefault("showInstructions", true) + view.showConfirmation = params.getBoolOrDefault("showConfirmation", true) + view.renderContent() + } + } + } + } + + override fun createViewInstance(p0: ThemedReactContext): SmileIDSmartSelfieCaptureView { + return SmileIDSmartSelfieCaptureView(reactApplicationContext) + } + + companion object { + const val NAME = "SmileIDSmartSelfieCaptureView" + const val COMMAND_SET_PARAMS = 1 + } +} diff --git a/android/src/main/java/com/smileidentity/react/views/SmileIDDocumentCaptureView.kt b/android/src/main/java/com/smileidentity/react/views/SmileIDDocumentCaptureView.kt new file mode 100644 index 0000000..6b527f1 --- /dev/null +++ b/android/src/main/java/com/smileidentity/react/views/SmileIDDocumentCaptureView.kt @@ -0,0 +1,109 @@ +package com.smileidentity.react.views + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.facebook.react.bridge.ReactApplicationContext +import com.smileidentity.R +import com.smileidentity.SmileID +import com.smileidentity.SmileIDOptIn +import com.smileidentity.compose.document.DocumentCaptureScreen +import com.smileidentity.compose.document.DocumentCaptureSide +import com.smileidentity.compose.theme.colorScheme +import com.smileidentity.react.utils.DocumentCaptureResultAdapter +import com.smileidentity.util.randomJobId +import com.squareup.moshi.JsonClass +import timber.log.Timber +import java.io.File + +data class DocumentCaptureResult( + val documentFrontFile: File? = null, + val documentBackFile: File? = null +) + +@OptIn(SmileIDOptIn::class) +class SmileIDDocumentCaptureView(context: ReactApplicationContext) : SmileIDView(context) { + var showConfirmation: Boolean = true + var front: Boolean = true + var allowGalleryUpload: Boolean = false + var idAspectRatio: Float? = null + + override fun renderContent() { + composeView.apply { + val customViewModelStoreOwner = CustomViewModelStoreOwner() + setContent { + CompositionLocalProvider(LocalViewModelStoreOwner provides customViewModelStoreOwner) { + val colorScheme = SmileID.colorScheme.copy(background = Color.White) + Box( + modifier = Modifier + .background(color = colorScheme.background) + .windowInsetsPadding(WindowInsets.statusBars) + .consumeWindowInsets(WindowInsets.statusBars) + .fillMaxSize() + ) { + RenderDocumentCaptureScreen() + } + } + } + } + } + + @Composable + private fun RenderDocumentCaptureScreen() { + val jobId = jobId ?: rememberSaveable { randomJobId() } + val hero = if (front) R.drawable.si_doc_v_front_hero else R.drawable.si_doc_v_back_hero + val instructionTitle = if (front) R.string.si_doc_v_instruction_title else + R.string.si_doc_v_instruction_back_title + val instructionSubTitle = if (front) R.string.si_verify_identity_instruction_subtitle else + R.string.si_doc_v_instruction_back_subtitle + val captureTitleText = if (front) R.string.si_doc_v_capture_instructions_front_title else + R.string.si_doc_v_capture_instructions_back_title + DocumentCaptureScreen( + jobId = jobId, + side = if (front) DocumentCaptureSide.Front else DocumentCaptureSide.Back, + showInstructions = showInstructions, + showAttribution = showAttribution, + allowGallerySelection = allowGalleryUpload, + showConfirmation = showConfirmation, + showSkipButton = false, + instructionsHeroImage = hero, + instructionsTitleText = stringResource(instructionTitle), + instructionsSubtitleText = stringResource(instructionSubTitle), + captureTitleText = stringResource(captureTitleText), + knownIdAspectRatio = idAspectRatio, + onConfirm = { file -> handleConfirmation(file) }, + onError = { throwable -> emitFailure(throwable) }, + onSkip = { } + ) + } + + private fun handleConfirmation(file: File) { + val newMoshi = SmileID.moshi.newBuilder() + .add(DocumentCaptureResultAdapter.FACTORY) + .build() + val result = DocumentCaptureResult( + documentFrontFile = if (front) file else null, + documentBackFile = if (!front) file else null, + ) + val json = try { + newMoshi + .adapter(DocumentCaptureResult::class.java) + .toJson(result) + } catch (e: Exception) { + Timber.w(e) + "null" + } + emitSuccess(json) + } +} diff --git a/android/src/main/java/com/smileidentity/react/views/SmileIDSmartSelfieCaptureView.kt b/android/src/main/java/com/smileidentity/react/views/SmileIDSmartSelfieCaptureView.kt new file mode 100644 index 0000000..0f44aa1 --- /dev/null +++ b/android/src/main/java/com/smileidentity/react/views/SmileIDSmartSelfieCaptureView.kt @@ -0,0 +1,213 @@ +package com.smileidentity.react.views + +import android.graphics.BitmapFactory +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.facebook.react.bridge.ReactApplicationContext +import com.smileidentity.R +import com.smileidentity.SmileID +import com.smileidentity.SmileIDOptIn +import com.smileidentity.compose.components.ImageCaptureConfirmationDialog +import com.smileidentity.compose.components.LocalMetadata +import com.smileidentity.compose.selfie.SelfieCaptureScreen +import com.smileidentity.compose.selfie.SmartSelfieInstructionsScreen +import com.smileidentity.compose.theme.colorScheme +import com.smileidentity.compose.theme.typography +import com.smileidentity.models.v2.Metadata +import com.smileidentity.react.utils.SelfieCaptureResultAdapter +import com.smileidentity.results.SmileIDResult +import com.smileidentity.util.randomJobId +import com.smileidentity.util.randomUserId +import com.smileidentity.viewmodel.SelfieUiState +import com.smileidentity.viewmodel.SelfieViewModel +import com.smileidentity.viewmodel.viewModelFactory +import java.io.File + +data class SmartSelfieCaptureResult( + val selfieFile: File? = null, + val livenessFiles: List? = null +) + +@OptIn(SmileIDOptIn::class) +class SmileIDSmartSelfieCaptureView(context: ReactApplicationContext) : SmileIDView(context) { + var showConfirmation: Boolean = true + + override fun renderContent() { + composeView.apply { + val customViewModelStoreOwner = CustomViewModelStoreOwner() + setContent { + CompositionLocalProvider(LocalViewModelStoreOwner provides customViewModelStoreOwner) { + RenderSmartSelfieCaptureContent() + } + } + } + } + + @Composable + private fun RenderSmartSelfieCaptureContent() { + val userId = randomUserId() + val jobId = randomJobId() + val metadata = LocalMetadata.current + val viewModel: SelfieViewModel = + viewModel( + factory = + viewModelFactory { + SelfieViewModel( + isEnroll = false, + userId = userId, + jobId = jobId, + allowNewEnroll = false, + skipApiSubmission = true, + metadata = metadata, + ) + }, + ) + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + var acknowledgedInstructions by rememberSaveable { mutableStateOf(false) } + CompositionLocalProvider( + LocalMetadata provides remember { Metadata.default().items.toMutableStateList() }, + ) { + MaterialTheme(colorScheme = SmileID.colorScheme, typography = SmileID.typography) { + Surface(content = { + when { + showInstructions && !acknowledgedInstructions -> SmartSelfieInstructionsScreen( + showAttribution = showAttribution, + ) { + acknowledgedInstructions = true + } + uiState.processingState != null -> HandleProcessingState(viewModel) + uiState.selfieToConfirm != null -> + HandleSelfieConfirmation( + showConfirmation, + uiState, + viewModel, + ) + + else -> RenderSelfieCaptureScreen(userId, jobId, allowAgentMode ?: true, viewModel) + } + }) + } + } + } + + @Composable + private fun RenderSelfieCaptureScreen( + userId: String, + jobId: String, + allowAgentMode: Boolean, + viewModel: SelfieViewModel, + ) { + Box( + modifier = + Modifier + .background(color = Color.White) + .windowInsetsPadding(WindowInsets.statusBars) + .consumeWindowInsets(WindowInsets.statusBars) + .fillMaxSize(), + ) { + SelfieCaptureScreen( + userId = userId, + jobId = jobId, + allowAgentMode = allowAgentMode, + allowNewEnroll = false, + skipApiSubmission = true, + viewModel = viewModel, + ) + } + } + + @Composable + private fun HandleSelfieConfirmation( + showConfirmation: Boolean, + uiState: SelfieUiState, + viewModel: SelfieViewModel, + ) { + if (showConfirmation) { + ImageCaptureConfirmationDialog( + titleText = stringResource(R.string.si_smart_selfie_confirmation_dialog_title), + subtitleText = + stringResource( + R.string.si_smart_selfie_confirmation_dialog_subtitle, + ), + painter = + BitmapPainter( + BitmapFactory + .decodeFile(uiState.selfieToConfirm!!.absolutePath) + .asImageBitmap(), + ), + confirmButtonText = + stringResource( + R.string.si_smart_selfie_confirmation_dialog_confirm_button, + ), + onConfirm = { + viewModel.submitJob() + }, + retakeButtonText = + stringResource( + R.string.si_smart_selfie_confirmation_dialog_retake_button, + ), + onRetake = viewModel::onSelfieRejected, + scaleFactor = 1.25f, + ) + } else { + viewModel.submitJob() + } + } + + @Composable + private fun HandleProcessingState(viewModel: SelfieViewModel) { + viewModel.onFinished { res -> + when (res) { + is SmileIDResult.Success -> { + val result = + SmartSelfieCaptureResult( + selfieFile = res.data.selfieFile, + livenessFiles = res.data.livenessFiles, + ) + val newMoshi = + SmileID.moshi + .newBuilder() + .add(SelfieCaptureResultAdapter.FACTORY) + .build() + val json = + try { + newMoshi + .adapter(SmartSelfieCaptureResult::class.java) + .toJson(result) + } catch (e: Exception) { + emitFailure(e) + return@onFinished + } + json?.let { js -> + emitSuccess(js) + } + } + + is SmileIDResult.Error -> emitFailure(res.throwable) + } + } + } +} diff --git a/android/src/main/java/com/smileidentity/react/views/SmileIDView.kt b/android/src/main/java/com/smileidentity/react/views/SmileIDView.kt index 199a4dc..e19cbea 100644 --- a/android/src/main/java/com/smileidentity/react/views/SmileIDView.kt +++ b/android/src/main/java/com/smileidentity/react/views/SmileIDView.kt @@ -1,5 +1,6 @@ package com.smileidentity.react.views +import android.annotation.SuppressLint import android.view.Choreographer import android.view.ViewGroup import android.widget.LinearLayout @@ -10,9 +11,12 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.events.RCTEventEmitter +import com.smileidentity.SmileID import com.smileidentity.models.JobType +import com.smileidentity.react.utils.DocumentCaptureResultAdapter import timber.log.Timber +@SuppressLint("CheckResult") abstract class SmileIDView(context: ReactApplicationContext) : LinearLayout(context) { lateinit var composeView: ComposeView var userId: String? = null @@ -20,8 +24,8 @@ abstract class SmileIDView(context: ReactApplicationContext) : LinearLayout(cont private var jobType: JobType? = null var allowAgentMode: Boolean? = false var allowNewEnroll: Boolean? = false - var showInstructions: Boolean? = true - var showAttribution: Boolean? = true + var showInstructions: Boolean = true + var showAttribution: Boolean = true var extraPartnerParams: Map? = null private var eventEmitter: RCTEventEmitter private var productThrowable: Throwable? = null @@ -31,6 +35,7 @@ abstract class SmileIDView(context: ReactApplicationContext) : LinearLayout(cont ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) + eventEmitter = (context as ReactContext).getJSModule(RCTEventEmitter::class.java); setLayoutParams(layoutParams) orientation = VERTICAL diff --git a/example/android/build.gradle b/example/android/build.gradle index c96174c..9ad2c5f 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -19,7 +19,7 @@ buildscript { } dependencies { classpath "com.facebook.react:react-native-gradle-plugin" - classpath "com.android.tools.build:gradle:8.3.2" + classpath 'com.android.tools.build:gradle:8.6.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.23" classpath "org.jlleitschuh.gradle:ktlint-gradle:12.1.1" } diff --git a/example/ios/Podfile b/example/ios/Podfile index 0868af8..66da687 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -16,7 +16,6 @@ end target 'SmileIdExample' do config = use_native_modules! - use_react_native!( :path => config[:reactNativePath], # An absolute path to your application root. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 215ef30..ca673dc 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,6 +2,11 @@ PODS: - boost (1.83.0) - DoubleConversion (1.1.6) - FBLazyVector (0.74.2) + - FingerprintJS (1.5.0): + - FingerprintJS/Core (= 1.5.0) + - FingerprintJS/Core (1.5.0): + - FingerprintJS/SystemControl + - FingerprintJS/SystemControl (1.5.0) - fmt (9.1.0) - glog (0.3.5) - hermes-engine (0.74.2): @@ -958,7 +963,7 @@ PODS: - React-utils - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - SmileID (= 10.2.8) + - SmileID (= 10.2.12) - Yoga - React-nativeconfig (0.74.2) - React-NativeModulesApple (0.74.2): @@ -1211,7 +1216,8 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - SmileID (10.2.8): + - SmileID (10.2.12): + - FingerprintJS - lottie-ios (~> 4.4.2) - ZIPFoundation (~> 0.9) - SocketRocket (0.7.0) @@ -1281,6 +1287,7 @@ DEPENDENCIES: SPEC REPOS: trunk: + - FingerprintJS - lottie-ios - SmileID - SocketRocket @@ -1407,6 +1414,7 @@ SPEC CHECKSUMS: boost: d3f49c53809116a5d38da093a8aa78bf551aed09 DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 FBLazyVector: 4bc164e5b5e6cfc288d2b5ff28643ea15fa1a589 + FingerprintJS: 96410117a394cca04d0f1e2374944c8697f2cceb fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 glog: fdfdfe5479092de0c4bdbebedd9056951f092c4f hermes-engine: 01d3e052018c2a13937aca1860fbedbccd4a41b7 @@ -1436,7 +1444,7 @@ SPEC CHECKSUMS: React-logger: 29fa3e048f5f67fe396bc08af7606426d9bd7b5d React-Mapbuffer: bf56147c9775491e53122a94c423ac201417e326 react-native-safe-area-context: a240ad4b683349e48b1d51fed1611138d1bdad97 - react-native-smile-id: f75eaf18d9f18fd71d24d4f19a83e29c505a14d7 + react-native-smile-id: 2af0431b4d8e1cf9102e1ae860270cdc6236569c React-nativeconfig: 9f223cd321823afdecf59ed00861ab2d69ee0fc1 React-NativeModulesApple: ff7efaff7098639db5631236cfd91d60abff04c0 React-perflogger: 32ed45d9cee02cf6639acae34251590dccd30994 @@ -1461,11 +1469,11 @@ SPEC CHECKSUMS: React-utils: 4476b7fcbbd95cfd002f3e778616155241d86e31 ReactCommon: ecad995f26e0d1e24061f60f4e5d74782f003f12 RNScreens: 5aeecbb09aa7285379b6e9f3c8a3c859bb16401c - SmileID: 757fe12fe0101a707563abf51c4aa27fe5598994 + SmileID: 6a7309335dafa915a23a6c4e8bfa4742aa483fc8 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: ae3c32c514802d30f687a04a6a35b348506d411f ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: e527f17ab57a2f8d8779ef014b3b6bc1aac3e8d6 +PODFILE CHECKSUM: 92128f18619da6c6e525b6e782e26be40527bc29 COCOAPODS: 1.15.2 diff --git a/example/src/HomeScreen.tsx b/example/src/HomeScreen.tsx index 6b8e997..df98af1 100644 --- a/example/src/HomeScreen.tsx +++ b/example/src/HomeScreen.tsx @@ -31,6 +31,10 @@ export const HomeScreen = ({ navigation }: { navigation: any }) => { jobId: '', allowAgentMode: true, showInstructions: true, + showAttribution: true, + showConfirmation: true, + isDocumentFrontSide: true, + allowGalleryUpload: true, }); const [userId, setUserId] = useState(generateUuid('user_')); const [jobId, setJobId] = useState(generateUuid('job_')); @@ -41,6 +45,14 @@ export const HomeScreen = ({ navigation }: { navigation: any }) => { optionalThingKey: 'optionalThingValue', }, }); + const [smartselfieCapture, setSmartselfieCapture] = + useState({ + ...defaultProductRef.current, + }); + const [documentCapture, setDocumentCapture] = + useState({ + ...defaultProductRef.current, + }); const [smartSelfieAuthentication, setSmartSelfieAuthentication] = useState({ ...defaultProductRef.current, @@ -98,6 +110,14 @@ export const HomeScreen = ({ navigation }: { navigation: any }) => { jobId, }; + setSmartselfieCapture({ + ...defaultProductRef.current, + }); + + setDocumentCapture({ + ...defaultProductRef.current, + }); + setSmartSelfieEnrollment({ ...defaultProductRef.current, extraPartnerParams: { @@ -145,6 +165,14 @@ export const HomeScreen = ({ navigation }: { navigation: any }) => { useEffect(() => { setSmileProducts([ + { + title: 'SmartSelfie Capture', + product: smartselfieCapture, + }, + { + title: 'Document Capture', + product: documentCapture, + }, { title: 'SmartSelfie Enrollment', product: smartSelfieEnrollment, @@ -171,6 +199,8 @@ export const HomeScreen = ({ navigation }: { navigation: any }) => { }, ]); }, [ + smartselfieCapture, + documentCapture, smartSelfieEnrollment, smartSelfieAuthentication, documentVerification, diff --git a/example/src/SmileIDCaptureScreen.tsx b/example/src/SmileIDCaptureScreen.tsx index 6968249..44be245 100644 --- a/example/src/SmileIDCaptureScreen.tsx +++ b/example/src/SmileIDCaptureScreen.tsx @@ -6,6 +6,7 @@ import { SmileIDSmartSelfieAuthenticationView, SmileIDDocumentVerificationView, SmileIDBiometricKYCView, + SmileIDDocumentCaptureView, SmileIDEnhancedDocumentVerificationView, AuthenticationRequest, JobType, @@ -13,6 +14,7 @@ import { SmileID, IdInfo, JobStatusRequest, + SmileIDSmartSelfieCaptureView, } from '@smile_identity/react-native'; import type { @@ -128,6 +130,34 @@ export const SmileIDCaptureScreen: React.FC = ({ return ( + {title === 'SmartSelfie Capture' && ( + // @ts-ignore - this is a known issue with the type definitions + { + if (event.nativeEvent.error) { + handleErrorResponse(event.nativeEvent.error); + return; + } + setResult(event.nativeEvent.result); + }} + /> + )} + {title === 'Document Capture' && ( + // @ts-ignore - this is a known issue with the type definitions + { + if (event.nativeEvent.error) { + handleErrorResponse(event.nativeEvent.error); + return; + } + setResult(event.nativeEvent.result); + }} + /> + )} {title === 'SmartSelfie Enrollment' && ( // @ts-ignore - this is a known issue with the type definitions String? { + guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + print("Unable to access documents directory") + return nil + } + + let smileIDDirectory = documentsDirectory.appendingPathComponent("SmileID") + return smileIDDirectory.absoluteURL.absoluteString + } + + func createSmileIDDirectoryIfNeeded() -> Bool { + guard let smileIDDirectory = getSmileIDDirectory() else { + return false + } + + if !fileManager.fileExists(atPath: smileIDDirectory) { + do { + try fileManager.createDirectory(atPath: smileIDDirectory, withIntermediateDirectories: true, attributes: nil) + return true + } catch { + print("Error creating SmileID directory: \(error)") + return false + } + } + + return true + } + + func getFilePath(fileName: String) -> String? { + guard let smileIDDirectory = getSmileIDDirectory() else { + return nil + } + + return (smileIDDirectory as NSString).appendingPathComponent(fileName) + } +} diff --git a/ios/ViewManagers/SmileIDDocumentCaptureViewManager.m b/ios/ViewManagers/SmileIDDocumentCaptureViewManager.m new file mode 100644 index 0000000..667d4b3 --- /dev/null +++ b/ios/ViewManagers/SmileIDDocumentCaptureViewManager.m @@ -0,0 +1,7 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(SmileIDDocumentCaptureViewManager, RCTViewManager) +RCT_EXTERN_METHOD(setParams:(nonnull NSNumber *)node params:(NSDictionary *)params) +RCT_EXPORT_VIEW_PROPERTY(onResult, RCTBubblingEventBlock); +@end diff --git a/ios/ViewManagers/SmileIDDocumentCaptureViewManager.swift b/ios/ViewManagers/SmileIDDocumentCaptureViewManager.swift new file mode 100644 index 0000000..c10932a --- /dev/null +++ b/ios/ViewManagers/SmileIDDocumentCaptureViewManager.swift @@ -0,0 +1,29 @@ +import Foundation +import React +import SwiftUI + +@objc(SmileIDDocumentCaptureViewManager) +class SmileIDDocumentCaptureViewManager: SmileIDBaseViewManager { + override func getView() -> UIView { + BaseSmileIDView(frame: .zero, contentView: AnyView(SmileIDDocumentCaptureView(product: self.product)), product: self.product) + } + + @objc func setParams(_ node: NSNumber, params: NSDictionary) { + /* UI Updates on the Main Thread:async ensures that the UI update is scheduled to run on the next cycle of the run loop, preventing any potential blocking of the UI if the update were to take a noticeable amount of time + */ + DispatchQueue.main.async { + if let component = self.bridge.uiManager.view(forReactTag: node) as? BaseSmileIDView { + self.product.extraPartnerParams = params["extraPartnerParams"] as? [String: String] ?? [:] + self.product.userId = params["userId"] as? String + self.product.jobId = params["jobId"] as? String + self.product.allowAgentMode = params["allowAgentMode"] as? Bool ?? false + self.product.front = params["isDocumentFrontSide"] as? Bool ?? true + self.product.showAttribution = params["showAttribution"] as? Bool ?? true + self.product.showInstructions = params["showInstructions"] as? Bool ?? true + self.product.showConfirmation = params["showConfirmation"] as? Bool ?? true + self.product.allowGalleryUpload = params["allowGalleryUpload"] as? Bool ?? false + self.product.onResult = params["onResult"] as? RCTBubblingEventBlock + } + } + } +} diff --git a/ios/ViewManagers/SmileIDSmartSelfieCaptureViewManager.m b/ios/ViewManagers/SmileIDSmartSelfieCaptureViewManager.m new file mode 100644 index 0000000..e34e523 --- /dev/null +++ b/ios/ViewManagers/SmileIDSmartSelfieCaptureViewManager.m @@ -0,0 +1,7 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(SmileIDSmartSelfieCaptureViewManager, RCTViewManager) +RCT_EXTERN_METHOD(setParams:(nonnull NSNumber *)node params:(NSDictionary *)params) +RCT_EXPORT_VIEW_PROPERTY(onResult, RCTBubblingEventBlock); +@end diff --git a/ios/ViewManagers/SmileIDSmartSelfieCaptureViewManager.swift b/ios/ViewManagers/SmileIDSmartSelfieCaptureViewManager.swift new file mode 100644 index 0000000..5315e2f --- /dev/null +++ b/ios/ViewManagers/SmileIDSmartSelfieCaptureViewManager.swift @@ -0,0 +1,36 @@ +import Foundation +import React +import SwiftUI +import SmileID + +@objc(SmileIDSmartSelfieCaptureViewManager) +class SmileIDSmartSelfieCaptureViewManager: SmileIDBaseViewManager { + override func getView() -> UIView { + BaseSmileIDView(frame: .zero, contentView: AnyView( + SmileIDSmartSelfieCaptureView( + viewModel: SelfieViewModel(isEnroll: false, + userId: self.product.userId ?? generateUserId(), + jobId: self.product.jobId ?? generateJobId(), + allowNewEnroll: false, + skipApiSubmission: true, + extraPartnerParams: [:], + localMetadata: LocalMetadata()), product: self.product)), + product: self.product) + } + + @objc func setParams(_ node: NSNumber, params: NSDictionary) { + /* UI Updates on the Main Thread:async ensures that the UI update is scheduled to run on the next cycle of the run loop, preventing any potential blocking of the UI if the update were to take a noticeable amount of time + */ + DispatchQueue.main.async { + if let component = self.bridge.uiManager.view(forReactTag: node) as? BaseSmileIDView { + self.product.allowAgentMode = params["allowAgentMode"] as? Bool ?? false + self.product.userId = params["userId"] as? String + self.product.jobId = params["jobId"] as? String + self.product.showConfirmation = params["showConfirmation"] as? Bool ?? true + self.product.showInstructions = params["showInstructions"] as? Bool ?? true + self.product.showAttribution = params["showAttribution"] as? Bool ?? true + self.product.onResult = params["onResult"] as? RCTBubblingEventBlock + } + } + } +} diff --git a/ios/ViewModels/SmileIDProductModel.swift b/ios/ViewModels/SmileIDProductModel.swift index 17484ec..3d1cac5 100644 --- a/ios/ViewModels/SmileIDProductModel.swift +++ b/ios/ViewModels/SmileIDProductModel.swift @@ -16,6 +16,8 @@ class SmileIDProductModel: ObservableObject { @Published var partnerPrivacyPolicy: String? @Published var allowAgentMode: Bool = false @Published var allowNewEnroll: Bool = false + @Published var front: Bool = true + @Published var showConfirmation: Bool = true @Published var showAttribution: Bool = true @Published var showInstructions: Bool = true @Published var extraPartnerParams: [String: String] = [:] diff --git a/react-native-smile-id.podspec b/react-native-smile-id.podspec index deac70e..4bcd551 100644 --- a/react-native-smile-id.podspec +++ b/react-native-smile-id.podspec @@ -15,7 +15,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://docs.usesmileid.com/.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" - s.dependency "SmileID", "10.2.8" + s.dependency "SmileID", "10.2.12" # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. diff --git a/src/SmileIDDocumentCaptureView.tsx b/src/SmileIDDocumentCaptureView.tsx new file mode 100644 index 0000000..53ec90f --- /dev/null +++ b/src/SmileIDDocumentCaptureView.tsx @@ -0,0 +1,44 @@ +import React, { Component } from 'react'; +import type { HostComponent } from 'react-native'; +import { UIManager, findNodeHandle, Platform } from 'react-native'; +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { SmartSelfieEnrollmentRequest } from './index'; + +const SmileIDDocumentCaptureComponent = + codegenNativeComponent( + 'SmileIDDocumentCaptureView' + ) as HostComponent; + +export default class SmileIDDocumentCaptureView extends Component { + private viewRef = React.createRef(); // + + componentDidMount() { + const parameters = { + ...this.props, + }; + + // Obtain the command identifier + const commandId = UIManager.getViewManagerConfig( + 'SmileIDDocumentCaptureView' + ).Commands.setParams; + + // Ensure the commandId is defined and is a number + if (typeof commandId !== 'undefined') { + UIManager.dispatchViewManagerCommand( + findNodeHandle(this.viewRef.current), + Platform.OS === 'android' ? commandId.toString() : commandId, + [parameters] + ); + } else { + throw new Error( + 'Command "setParams" is not defined for MyNativeComponent' + ); + } + } + + render() { + return ( + + ); + } +} diff --git a/src/SmileIDSmartSelfieCaptureView.tsx b/src/SmileIDSmartSelfieCaptureView.tsx new file mode 100644 index 0000000..21096e8 --- /dev/null +++ b/src/SmileIDSmartSelfieCaptureView.tsx @@ -0,0 +1,44 @@ +import React, { Component } from 'react'; +import type { HostComponent } from 'react-native'; +import { UIManager, findNodeHandle, Platform } from 'react-native'; +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { SmartSelfieEnrollmentRequest } from './index'; + +const SmileIDSmartSelfieCaptureComponent = + codegenNativeComponent( + 'SmileIDSmartSelfieCaptureView' + ) as HostComponent; + +export default class SmileIDSmartSelfieCaptureView extends Component { + private viewRef = React.createRef(); // + + componentDidMount() { + const parameters = { + ...this.props, + }; + + // Obtain the command identifier + const commandId = UIManager.getViewManagerConfig( + 'SmileIDSmartSelfieCaptureView' + ).Commands.setParams; + + // Ensure the commandId is defined and is a number + if (typeof commandId !== 'undefined') { + UIManager.dispatchViewManagerCommand( + findNodeHandle(this.viewRef.current), + Platform.OS === 'android' ? commandId.toString() : commandId, + [parameters] + ); + } else { + throw new Error( + 'Command "setParams" is not defined for MyNativeComponent' + ); + } + } + + render() { + return ( + + ); + } +} diff --git a/src/index.tsx b/src/index.tsx index 1a5d657..e752520 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,8 @@ import SmileIDSmartSelfieAuthenticationView from './SmileIDSmartSelfieAuthentica import SmileIDDocumentVerificationView from './SmileIDDocumentVerificationView'; import SmileIDBiometricKYCView from './SmileIDBiometricKYCView'; import SmileIDEnhancedDocumentVerificationView from './SmileIDEnhancedDocumentVerificationView'; +import SmileIDSmartSelfieCaptureView from './SmileIDSmartSelfieCaptureView'; +import SmileIDDocumentCaptureView from './SmileIDDocumentCaptureView'; import SmileIDConsentView from './SmileIDConsentView'; import { AuthenticationRequest, @@ -263,6 +265,8 @@ export { SmileIDDocumentVerificationView, SmileIDBiometricKYCView, SmileIDEnhancedDocumentVerificationView, + SmileIDSmartSelfieCaptureView, + SmileIDDocumentCaptureView, SmileIDConsentView, EnhancedKycRequest, JobType, diff --git a/src/types.ts b/src/types.ts index 5b71a36..aee2b4f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,11 @@ type SmartSelfieRequest = SmileIDViewProps & { * Whether to allow the user to reentoll */ allowNewEnroll?: boolean; + + /** + * Used for selfie capture screens to show the confirmation dialog + */ + showConfirmation?: boolean; }; export type SmartSelfieEnrollmentRequest = SmartSelfieRequest; @@ -97,6 +102,12 @@ export type DocumentVerificationRequest = SmartSelfieRequest & { * If provided, selfie capture will be bypassed using this image. */ bypassSelfieCaptureWithFile?: string; + + /** + * If true, document capture instruction and prompts + * will be for front side of the document + */ + isDocumentFrontSide?: boolean; }; export type ConsentRequest = Omit & {