diff --git a/.editorconfig b/.editorconfig index d8ba2b8b..765c4cec 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,2 +1,6 @@ [*] max_line_length=100 + +[{*.kt,*.kts}] +ij_kotlin_allow_trailing_comma_on_call_site=true +ij_kotlin_allow_trailing_comma=true \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 195cf352..1d15553c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,6 +9,19 @@ concurrency: cancel-in-progress: true jobs: + Flutter: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.10.6' # Set the desired Flutter version here + channel: 'stable' + - name: Install Dependencies + run: flutter pub get && cd example && flutter pub get && cd .. + - name: Lint Flutter + run: flutter analyze Android: runs-on: ubuntu-latest timeout-minutes: 10 @@ -24,6 +37,13 @@ jobs: channel: 'stable' - name: Install Dependencies run: flutter pub get && cd example && flutter pub get && cd .. + - name: Lint Android + uses: musichin/ktlint-check@v2 + with: + ktlint-version: "0.49.1" + patterns: | + **/**.kt + !**/generated/** - name: Test run: flutter test && cd example && flutter test && cd .. - name: Build Android Sample App @@ -41,4 +61,4 @@ jobs: - name: Install Dependencies run: flutter pub get && cd example && flutter pub get && cd .. - name: Build iOS Sample App - run: cd example && flutter build ios --no-codesign && cd .. + run: cd example && flutter build ios --no-codesign && cd .. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f914c36..d328724c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +# 10.0.0-beta02 +- Support for Document Verification, exposed as a `SmileIDDocumentVerification` Widget +- Support for SmartSelfie Authentication, exposed as a `SmileIDSmartSelfieAuthentication` Widget + # 10.0.0-beta01 - Initial release - Support for Enhanced KYC (Async) diff --git a/analysis_options.yaml b/analysis_options.yaml index a5744c1c..138c7874 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,3 +2,42 @@ include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options + +#Official linting recommendations by the Flutter and Dart team. +# It has 2 sets of linters: +# Core (around 30 rules) - https://github.com/dart-lang/lints/blob/main/lib/core.yaml +# Recommended (around 55 rules) - https://github.com/dart-lang/lints/blob/main/lib/recommended.yaml + +linter: + rules: + - avoid_empty_else + - avoid_relative_lib_imports + - avoid_shadowing_type_parameters + - avoid_types_as_parameter_names + - await_only_futures + - camel_case_extensions + - camel_case_types + - collection_methods_unrelated_type + - curly_braces_in_flow_control_structures + - depend_on_referenced_packages + - empty_catches + - file_names + - hash_and_equals + - implicit_call_tearoffs + - no_duplicate_case_values + - non_constant_identifier_names + - null_check_on_nullable_type_parameter + - package_prefixed_library_names + - prefer_generic_function_type_aliases + - prefer_is_empty + - prefer_is_not_empty + - prefer_iterable_whereType + - prefer_typing_uninitialized_variables + - provide_deprecation_message + - secure_pubspec_urls + - type_literal_in_constant_pattern + - unnecessary_overrides + - unrelated_type_equality_checks + - use_string_in_part_of_directives + - valid_regexps + - void_checks \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 2eb7dd55..523bf09b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,23 +1,23 @@ group "com.smileidentity.flutter" -version findProperty("SDK_VERSION") ?: "10.0.0-beta06-SNAPSHOT" +version findProperty("SDK_VERSION") ?: "10.0.0-beta08" buildscript { - ext.kotlin_version = "1.9.0" + ext.kotlin_version = "1.9.10" repositories { - maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } google() mavenCentral() + maven { url "https://plugins.gradle.org/m2/" } } dependencies { - classpath "com.android.tools.build:gradle:8.1.0" + classpath "com.android.tools.build:gradle:8.1.2" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jlleitschuh.gradle:ktlint-gradle:11.6.0" } } allprojects { repositories { - maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } google() mavenCentral() } @@ -25,6 +25,7 @@ allprojects { apply plugin: "com.android.library" apply plugin: "kotlin-android" +apply plugin: "org.jlleitschuh.gradle.ktlint" android { namespace group @@ -51,15 +52,27 @@ android { } } + buildFeatures.compose = true + composeOptions { + kotlinCompilerExtensionVersion = "1.5.3" + } + dependencies { implementation "com.smileidentity:android-sdk:${version}" - - implementation "com.squareup.okhttp3:okhttp" implementation "androidx.core:core-ktx" + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.material3:material3" + implementation "androidx.fragment:fragment-ktx" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core" - implementation "com.jakewharton.timber:timber" testImplementation "org.jetbrains.kotlin:kotlin-test" - testImplementation "io.mockk:mockk:1.13.5" + testImplementation "io.mockk:mockk:1.13.8" } } + +ktlint { + android = true + filter { + exclude { it.file.path.contains(".g.kt") } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/smileidentity/flutter/mapper.kt b/android/src/main/kotlin/com/smileidentity/flutter/Mapper.kt similarity index 89% rename from android/src/main/kotlin/com/smileidentity/flutter/mapper.kt rename to android/src/main/kotlin/com/smileidentity/flutter/Mapper.kt index 0d824b65..66599bfb 100644 --- a/android/src/main/kotlin/com/smileidentity/flutter/mapper.kt +++ b/android/src/main/kotlin/com/smileidentity/flutter/Mapper.kt @@ -35,12 +35,14 @@ fun convertNonNullMapToNullable(map: Map): Map map.mapKeys { it.key } .mapValues { it.value } -fun FlutterJobType.toRequest() = when(this) { +fun FlutterJobType.toRequest() = when (this) { FlutterJobType.ENHANCEDKYC -> JobType.EnhancedKyc + FlutterJobType.DOCUMENTVERIFICATION -> JobType.DocumentVerification } fun JobType.toResponse() = when (this) { JobType.EnhancedKyc -> FlutterJobType.ENHANCEDKYC + JobType.DocumentVerification -> FlutterJobType.DOCUMENTVERIFICATION else -> TODO("Not yet implemented") } @@ -57,19 +59,19 @@ fun PartnerParams.toResponse() = FlutterPartnerParams( jobType = jobType?.toResponse(), jobId = jobId, userId = userId, - extras = convertNonNullMapToNullable(extras) + extras = convertNonNullMapToNullable(extras), ) fun FlutterPartnerParams.toRequest() = PartnerParams( jobType = jobType?.toRequest(), jobId = jobId, userId = userId, - extras = convertNullableMapToNonNull(extras) + extras = convertNullableMapToNonNull(extras), ) fun ConsentInfo.toRequest() = FlutterConsentInfo( canAccess = canAccess, - consentRequired = consentRequired + consentRequired = consentRequired, ) fun AuthenticationResponse.toResponse() = FlutterAuthenticationResponse( @@ -96,7 +98,7 @@ fun FlutterEnhancedKycRequest.toRequest() = EnhancedKycRequest( jobType = partnerParams.jobType?.toRequest(), jobId = partnerParams.jobId, userId = partnerParams.userId, - extras = convertNullableMapToNonNull(partnerParams.extras) + extras = convertNullableMapToNonNull(partnerParams.extras), ), sourceSdk = "android (flutter)", timestamp = timestamp, @@ -104,5 +106,5 @@ fun FlutterEnhancedKycRequest.toRequest() = EnhancedKycRequest( ) fun EnhancedKycAsyncResponse.toResponse() = FlutterEnhancedKycAsyncResponse( - success = success + success = success, ) diff --git a/android/src/main/kotlin/com/smileidentity/flutter/Messages.g.kt b/android/src/main/kotlin/com/smileidentity/flutter/Messages.g.kt deleted file mode 100644 index ad56a66c..00000000 --- a/android/src/main/kotlin/com/smileidentity/flutter/Messages.g.kt +++ /dev/null @@ -1,435 +0,0 @@ -// Autogenerated from Pigeon (v10.1.6), do not edit directly. -// See also: https://pub.dev/packages/pigeon - - -import android.util.Log -import io.flutter.plugin.common.BasicMessageChannel -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MessageCodec -import io.flutter.plugin.common.StandardMessageCodec -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer - -private fun wrapResult(result: Any?): List { - return listOf(result) -} - -private fun wrapError(exception: Throwable): List { - if (exception is FlutterError) { - return listOf( - exception.code, - exception.message, - exception.details - ) - } else { - return listOf( - exception.javaClass.simpleName, - exception.toString(), - "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) - ) - } -} - -/** - * Error class for passing custom error details to Flutter via a thrown PlatformException. - * @property code The error code. - * @property message The error message. - * @property details The error details. Must be a datatype supported by the api codec. - */ -class FlutterError ( - val code: String, - override val message: String? = null, - val details: Any? = null -) : Throwable() - -enum class FlutterJobType(val raw: Int) { - ENHANCEDKYC(0); - - companion object { - fun ofRaw(raw: Int): FlutterJobType? { - return values().firstOrNull { it.raw == raw } - } - } -} - -/** - * Custom values specific to partners can be placed in [extras] - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class FlutterPartnerParams ( - val jobType: FlutterJobType? = null, - val jobId: String, - val userId: String, - val extras: Map? = null - -) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): FlutterPartnerParams { - val jobType: FlutterJobType? = (list[0] as Int?)?.let { - FlutterJobType.ofRaw(it) - } - val jobId = list[1] as String - val userId = list[2] as String - val extras = list[3] as Map? - return FlutterPartnerParams(jobType, jobId, userId, extras) - } - } - fun toList(): List { - return listOf( - jobType?.raw, - jobId, - userId, - extras, - ) - } -} - -/** - * The Auth Smile request. Auth Smile serves multiple purposes: - * - * - It is used to fetch the signature needed for subsequent API requests - * - It indicates the type of job that will being performed - * - It is used to fetch consent information for the partner - * - * [jobType] The type of job that will be performed - * [country] The country code of the country where the job is being performed. This value is - * required in order to get back consent information for the partner - * [idType] The type of ID that will be used for the job. This value is required in order to - * get back consent information for the partner - * [updateEnrolledImage] Whether or not the enrolled image should be updated with image - * submitted for this job - * [jobId] The job ID to associate with the job. Most often, this will correspond to a unique - * Job ID within your own system. If not provided, a random job ID will be generated - * [userId] The user ID to associate with the job. Most often, this will correspond to a unique - * User ID within your own system. If not provided, a random user ID will be generated - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class FlutterAuthenticationRequest ( - val jobType: FlutterJobType, - val country: String? = null, - val idType: String? = null, - val updateEnrolledImage: Boolean? = null, - val jobId: String? = null, - val userId: String? = null - -) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): FlutterAuthenticationRequest { - val jobType = FlutterJobType.ofRaw(list[0] as Int)!! - val country = list[1] as String? - val idType = list[2] as String? - val updateEnrolledImage = list[3] as Boolean? - val jobId = list[4] as String? - val userId = list[5] as String? - return FlutterAuthenticationRequest(jobType, country, idType, updateEnrolledImage, jobId, userId) - } - } - fun toList(): List { - return listOf( - jobType.raw, - country, - idType, - updateEnrolledImage, - jobId, - userId, - ) - } -} - -/** - * [consentInfo] is only populated when a country and ID type are provided in the - * [FlutterAuthenticationRequest]. To get information about *all* countries and ID types instead, - * [SmileIDService.getProductsConfig] - * - * [timestamp] is *not* a [DateTime] because technically, any arbitrary value could have been - * passed to it. This applies to all other timestamp fields in the SDK. - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class FlutterAuthenticationResponse ( - val success: Boolean, - val signature: String, - val timestamp: String, - val partnerParams: FlutterPartnerParams, - val callbackUrl: String? = null, - val consentInfo: FlutterConsentInfo? = null - -) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): FlutterAuthenticationResponse { - val success = list[0] as Boolean - val signature = list[1] as String - val timestamp = list[2] as String - val partnerParams = FlutterPartnerParams.fromList(list[3] as List) - val callbackUrl = list[4] as String? - val consentInfo: FlutterConsentInfo? = (list[5] as List?)?.let { - FlutterConsentInfo.fromList(it) - } - return FlutterAuthenticationResponse(success, signature, timestamp, partnerParams, callbackUrl, consentInfo) - } - } - fun toList(): List { - return listOf( - success, - signature, - timestamp, - partnerParams.toList(), - callbackUrl, - consentInfo?.toList(), - ) - } -} - -/** - * [canAccess] Whether or not the ID type is enabled for the partner - * [consentRequired] Whether or not consent is required for the ID type - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class FlutterConsentInfo ( - val canAccess: Boolean, - val consentRequired: Boolean - -) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): FlutterConsentInfo { - val canAccess = list[0] as Boolean - val consentRequired = list[1] as Boolean - return FlutterConsentInfo(canAccess, consentRequired) - } - } - fun toList(): List { - return listOf( - canAccess, - consentRequired, - ) - } -} - -/** - * [timestamp] is *not* a [DateTime] because technically, any arbitrary value could have been - * passed to it. This applies to all other timestamp fields in the SDK. - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class FlutterEnhancedKycRequest ( - val country: String, - val idType: String, - val idNumber: String, - val firstName: String? = null, - val middleName: String? = null, - val lastName: String? = null, - val dob: String? = null, - val phoneNumber: String? = null, - val bankCode: String? = null, - val callbackUrl: String? = null, - val partnerParams: FlutterPartnerParams, - val timestamp: String, - val signature: String - -) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): FlutterEnhancedKycRequest { - val country = list[0] as String - val idType = list[1] as String - val idNumber = list[2] as String - val firstName = list[3] as String? - val middleName = list[4] as String? - val lastName = list[5] as String? - val dob = list[6] as String? - val phoneNumber = list[7] as String? - val bankCode = list[8] as String? - val callbackUrl = list[9] as String? - val partnerParams = FlutterPartnerParams.fromList(list[10] as List) - val timestamp = list[11] as String - val signature = list[12] as String - return FlutterEnhancedKycRequest(country, idType, idNumber, firstName, middleName, lastName, dob, phoneNumber, bankCode, callbackUrl, partnerParams, timestamp, signature) - } - } - fun toList(): List { - return listOf( - country, - idType, - idNumber, - firstName, - middleName, - lastName, - dob, - phoneNumber, - bankCode, - callbackUrl, - partnerParams.toList(), - timestamp, - signature, - ) - } -} - -/** Generated class from Pigeon that represents data sent in messages. */ -data class FlutterEnhancedKycAsyncResponse ( - val success: Boolean - -) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): FlutterEnhancedKycAsyncResponse { - val success = list[0] as Boolean - return FlutterEnhancedKycAsyncResponse(success) - } - } - fun toList(): List { - return listOf( - success, - ) - } -} - -@Suppress("UNCHECKED_CAST") -private object SmileIDApiCodec : StandardMessageCodec() { - override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return when (type) { - 128.toByte() -> { - return (readValue(buffer) as? List)?.let { - FlutterAuthenticationRequest.fromList(it) - } - } - 129.toByte() -> { - return (readValue(buffer) as? List)?.let { - FlutterAuthenticationResponse.fromList(it) - } - } - 130.toByte() -> { - return (readValue(buffer) as? List)?.let { - FlutterConsentInfo.fromList(it) - } - } - 131.toByte() -> { - return (readValue(buffer) as? List)?.let { - FlutterEnhancedKycAsyncResponse.fromList(it) - } - } - 132.toByte() -> { - return (readValue(buffer) as? List)?.let { - FlutterEnhancedKycRequest.fromList(it) - } - } - 133.toByte() -> { - return (readValue(buffer) as? List)?.let { - FlutterPartnerParams.fromList(it) - } - } - else -> super.readValueOfType(type, buffer) - } - } - override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - when (value) { - is FlutterAuthenticationRequest -> { - stream.write(128) - writeValue(stream, value.toList()) - } - is FlutterAuthenticationResponse -> { - stream.write(129) - writeValue(stream, value.toList()) - } - is FlutterConsentInfo -> { - stream.write(130) - writeValue(stream, value.toList()) - } - is FlutterEnhancedKycAsyncResponse -> { - stream.write(131) - writeValue(stream, value.toList()) - } - is FlutterEnhancedKycRequest -> { - stream.write(132) - writeValue(stream, value.toList()) - } - is FlutterPartnerParams -> { - stream.write(133) - writeValue(stream, value.toList()) - } - else -> super.writeValue(stream, value) - } - } -} - -/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ -interface SmileIDApi { - fun initialize() - fun authenticate(request: FlutterAuthenticationRequest, callback: (Result) -> Unit) - fun doEnhancedKycAsync(request: FlutterEnhancedKycRequest, callback: (Result) -> Unit) - - companion object { - /** The codec used by SmileIDApi. */ - val codec: MessageCodec by lazy { - SmileIDApiCodec - } - /** Sets up an instance of `SmileIDApi` to handle messages through the `binaryMessenger`. */ - @Suppress("UNCHECKED_CAST") - fun setUp(binaryMessenger: BinaryMessenger, api: SmileIDApi?) { - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.smileid.SmileIDApi.initialize", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - var wrapped: List - try { - api.initialize() - wrapped = listOf(null) - } catch (exception: Throwable) { - wrapped = wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.smileid.SmileIDApi.authenticate", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val requestArg = args[0] as FlutterAuthenticationRequest - api.authenticate(requestArg) { result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(wrapError(error)) - } else { - val data = result.getOrNull() - reply.reply(wrapResult(data)) - } - } - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.smileid.SmileIDApi.doEnhancedKycAsync", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val requestArg = args[0] as FlutterEnhancedKycRequest - api.doEnhancedKycAsync(requestArg) { result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(wrapError(error)) - } else { - val data = result.getOrNull() - reply.reply(wrapResult(data)) - } - } - } - } else { - channel.setMessageHandler(null) - } - } - } - } -} diff --git a/android/src/main/kotlin/com/smileidentity/flutter/SmileIDDocumentVerification.kt b/android/src/main/kotlin/com/smileidentity/flutter/SmileIDDocumentVerification.kt new file mode 100644 index 00000000..a7d06267 --- /dev/null +++ b/android/src/main/kotlin/com/smileidentity/flutter/SmileIDDocumentVerification.kt @@ -0,0 +1,97 @@ +package com.smileidentity.flutter + +import android.content.Context +import android.view.View +import androidx.compose.ui.platform.ComposeView +import com.smileidentity.SmileID +import com.smileidentity.compose.DocumentVerification +import com.smileidentity.results.DocumentVerificationResult +import com.smileidentity.results.SmileIDResult +import com.smileidentity.util.randomJobId +import com.smileidentity.util.randomUserId +import io.flutter.Log +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory +import java.io.File + +internal class SmileIDDocumentVerification private constructor( + context: Context, + viewId: Int, + messenger: BinaryMessenger, + args: Map, +) : PlatformView { + companion object { + const val VIEW_TYPE_ID = "SmileIDDocumentVerification" + } + + private val methodChannel: MethodChannel + private val view: View + + init { + methodChannel = MethodChannel(messenger, "${VIEW_TYPE_ID}_$viewId") + view = ComposeView(context).apply { + setContent { + SmileID.DocumentVerification( + countryCode = args["countryCode"] as String, + documentType = args["documentType"] as? String, + idAspectRatio = (args["idAspectRatio"] as Double?)?.toFloat(), + captureBothSides = args["captureBothSides"] as? Boolean ?: true, + bypassSelfieCaptureWithFile = + (args["bypassSelfieCaptureWithFile"] as? String)?.let { File(it) }, + userId = args["userId"] as? String ?: randomUserId(), + jobId = args["jobId"] as? String ?: randomJobId(), + showAttribution = args["showAttribution"] as? Boolean ?: true, + allowGalleryUpload = args["allowGalleryUpload"] as? Boolean ?: false, + showInstructions = args["showInstructions"] as? Boolean ?: true, + ) { + when (it) { + is SmileIDResult.Success -> { + // At this point, we have a successful result from the native SDK. But, + // there is still a possibility of the JSON serializing erroring for + // whatever reason -- if such a thing happens, we still want to tell + // the caller that the overall operation was successful. However, we + // just may not be able to provide the result JSON. + val json = try { + SmileID.moshi + .adapter(DocumentVerificationResult::class.java) + .toJson(it.data) + } catch (e: Exception) { + Log.e("SmileIDDocumentVerification", "Error serializing result", e) + "null" + } + methodChannel.invokeMethod("onSuccess", json) + } + + is SmileIDResult.Error -> { + // Print the stack trace, since we can't provide the actual Throwable + // back to Flutter + it.throwable.printStackTrace() + methodChannel.invokeMethod("onError", it.throwable.message) + } + } + } + } + } + } + + override fun getView() = view + + override fun dispose() = Unit + + class Factory( + private val messenger: BinaryMessenger, + ) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + @Suppress("UNCHECKED_CAST") + return SmileIDDocumentVerification( + context, + viewId, + messenger, + args as Map, + ) + } + } +} diff --git a/android/src/main/kotlin/com/smileidentity/flutter/SmileIDPlugin.kt b/android/src/main/kotlin/com/smileidentity/flutter/SmileIDPlugin.kt index b2941cd7..56eeec7e 100644 --- a/android/src/main/kotlin/com/smileidentity/flutter/SmileIDPlugin.kt +++ b/android/src/main/kotlin/com/smileidentity/flutter/SmileIDPlugin.kt @@ -5,8 +5,8 @@ import FlutterAuthenticationResponse import FlutterEnhancedKycAsyncResponse import FlutterEnhancedKycRequest import SmileIDApi +import android.app.Activity import android.content.Context -import android.util.Log import com.smileidentity.SmileID import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -18,11 +18,27 @@ import kotlinx.coroutines.launch class SmileIDPlugin : FlutterPlugin, SmileIDApi, ActivityAware { - private lateinit var context: Context + private var activity: Activity? = null + private lateinit var appContext: Context override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { SmileIDApi.setUp(flutterPluginBinding.binaryMessenger, this) - context = flutterPluginBinding.applicationContext + appContext = flutterPluginBinding.applicationContext + + flutterPluginBinding.platformViewRegistry.registerViewFactory( + SmileIDDocumentVerification.VIEW_TYPE_ID, + SmileIDDocumentVerification.Factory(flutterPluginBinding.binaryMessenger), + ) + + flutterPluginBinding.platformViewRegistry.registerViewFactory( + SmileIDSmartSelfieEnrollment.VIEW_TYPE_ID, + SmileIDSmartSelfieEnrollment.Factory(flutterPluginBinding.binaryMessenger), + ) + + flutterPluginBinding.platformViewRegistry.registerViewFactory( + SmileIDSmartSelfieAuthentication.VIEW_TYPE_ID, + SmileIDSmartSelfieAuthentication.Factory(flutterPluginBinding.binaryMessenger), + ) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -30,23 +46,23 @@ class SmileIDPlugin : FlutterPlugin, SmileIDApi, ActivityAware { } override fun initialize() { - SmileID.initialize(context) + SmileID.initialize(appContext, enableCrashReporting = false) } override fun authenticate( request: FlutterAuthenticationRequest, - callback: (Result) -> Unit + callback: (Result) -> Unit, ) = launch( work = { SmileID.api.authenticate(request.toRequest()).toResponse() }, - callback = callback + callback = callback, ) override fun doEnhancedKycAsync( request: FlutterEnhancedKycRequest, - callback: (Result) -> Unit + callback: (Result) -> Unit, ) = launch( work = { SmileID.api.doEnhancedKycAsync(request.toRequest()).toResponse() }, - callback = callback + callback = callback, ) /** @@ -55,7 +71,9 @@ class SmileIDPlugin : FlutterPlugin, SmileIDApi, ActivityAware { * We can get the context in a ActivityAware way, without asking users to pass the context when * calling "initialize" on the sdk */ - override fun onAttachedToActivity(binding: ActivityPluginBinding) {} + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + this.activity = binding.activity + } override fun onDetachedFromActivityForConfigChanges() {} @@ -71,7 +89,7 @@ class SmileIDPlugin : FlutterPlugin, SmileIDApi, ActivityAware { private fun launch( work: suspend () -> T, callback: (Result) -> Unit, - scope: CoroutineScope = CoroutineScope(Dispatchers.IO) + scope: CoroutineScope = CoroutineScope(Dispatchers.IO), ) { val handler = CoroutineExceptionHandler { _, throwable -> callback.invoke(Result.failure(throwable)) diff --git a/android/src/main/kotlin/com/smileidentity/flutter/SmileIDSmartSelfieAuthentication.kt b/android/src/main/kotlin/com/smileidentity/flutter/SmileIDSmartSelfieAuthentication.kt new file mode 100644 index 00000000..eceda15a --- /dev/null +++ b/android/src/main/kotlin/com/smileidentity/flutter/SmileIDSmartSelfieAuthentication.kt @@ -0,0 +1,93 @@ +package com.smileidentity.flutter + +import android.content.Context +import android.view.View +import androidx.compose.ui.platform.ComposeView +import com.smileidentity.SmileID +import com.smileidentity.compose.SmartSelfieAuthentication +import com.smileidentity.results.SmartSelfieResult +import com.smileidentity.results.SmileIDResult +import com.smileidentity.util.randomJobId +import com.smileidentity.util.randomUserId +import io.flutter.Log +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +internal class SmileIDSmartSelfieAuthentication private constructor( + context: Context, + viewId: Int, + messenger: BinaryMessenger, + args: Map, +) : PlatformView { + companion object { + const val VIEW_TYPE_ID = "SmileIDSmartSelfieAuthentication" + } + + private val methodChannel: MethodChannel + private val view: View + + init { + methodChannel = MethodChannel(messenger, "${VIEW_TYPE_ID}_$viewId") + view = ComposeView(context).apply { + setContent { + SmileID.SmartSelfieAuthentication( + userId = args["userId"] as? String ?: randomUserId(), + jobId = args["jobId"] as? String ?: randomJobId(), + allowAgentMode = args["allowAgentMode"] as? Boolean ?: false, + showAttribution = args["showAttribution"] as? Boolean ?: true, + ) { + when (it) { + is SmileIDResult.Success -> { + // At this point, we have a successful result from the native SDK. But, + // there is still a possibility of the JSON serializing erroring for + // whatever reason -- if such a thing happens, we still want to tell + // the caller that the overall operation was successful. However, we + // just may not be able to provide the result JSON. + val json = try { + SmileID.moshi + .adapter(SmartSelfieResult::class.java) + .toJson(it.data) + } catch (e: Exception) { + Log.e( + "SmileIDSmartSelfieAuthentication", + "Error serializing result", + e, + ) + "null" + } + methodChannel.invokeMethod("onSuccess", json) + } + + is SmileIDResult.Error -> { + // Print the stack trace, since we can't provide the actual Throwable + // back to Flutter + it.throwable.printStackTrace() + methodChannel.invokeMethod("onError", it.throwable.message) + } + } + } + } + } + } + + override fun getView() = view + + override fun dispose() = Unit + + class Factory( + private val messenger: BinaryMessenger, + ) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + @Suppress("UNCHECKED_CAST") + return SmileIDSmartSelfieAuthentication( + context, + viewId, + messenger, + args as Map, + ) + } + } +} diff --git a/android/src/main/kotlin/com/smileidentity/flutter/SmileIDSmartSelfieEnrollment.kt b/android/src/main/kotlin/com/smileidentity/flutter/SmileIDSmartSelfieEnrollment.kt new file mode 100644 index 00000000..eba74c90 --- /dev/null +++ b/android/src/main/kotlin/com/smileidentity/flutter/SmileIDSmartSelfieEnrollment.kt @@ -0,0 +1,90 @@ +package com.smileidentity.flutter + +import android.content.Context +import android.view.View +import androidx.compose.ui.platform.ComposeView +import com.smileidentity.SmileID +import com.smileidentity.compose.SmartSelfieEnrollment +import com.smileidentity.results.SmartSelfieResult +import com.smileidentity.results.SmileIDResult +import com.smileidentity.util.randomJobId +import com.smileidentity.util.randomUserId +import io.flutter.Log +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +internal class SmileIDSmartSelfieEnrollment private constructor( + context: Context, + viewId: Int, + messenger: BinaryMessenger, + args: Map, +) : PlatformView { + companion object { + const val VIEW_TYPE_ID = "SmileIDSmartSelfieEnrollment" + } + + private val methodChannel: MethodChannel + private val view: View + + init { + methodChannel = MethodChannel(messenger, "${VIEW_TYPE_ID}_$viewId") + view = ComposeView(context).apply { + setContent { + SmileID.SmartSelfieEnrollment( + userId = args["userId"] as? String ?: randomUserId(), + jobId = args["jobId"] as? String ?: randomJobId(), + allowAgentMode = args["allowAgentMode"] as? Boolean ?: false, + showAttribution = args["showAttribution"] as? Boolean ?: true, + showInstructions = args["showInstructions"] as? Boolean ?: true, + ) { + when (it) { + is SmileIDResult.Success -> { + // At this point, we have a successful result from the native SDK. But, + // there is still a possibility of the JSON serializing erroring for + // whatever reason -- if such a thing happens, we still want to tell + // the caller that the overall operation was successful. However, we + // just may not be able to provide the result JSON. + val json = try { + SmileID.moshi + .adapter(SmartSelfieResult::class.java) + .toJson(it.data) + } catch (e: Exception) { + Log.e("SmileIDSmartSelfieEnrollment", "Error serializing result", e) + "null" + } + methodChannel.invokeMethod("onSuccess", json) + } + + is SmileIDResult.Error -> { + // Print the stack trace, since we can't provide the actual Throwable + // back to Flutter + it.throwable.printStackTrace() + methodChannel.invokeMethod("onError", it.throwable.message) + } + } + } + } + } + } + + override fun getView() = view + + override fun dispose() = Unit + + class Factory( + private val messenger: BinaryMessenger, + ) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + @Suppress("UNCHECKED_CAST") + return SmileIDSmartSelfieEnrollment( + context, + viewId, + messenger, + args as Map, + ) + } + } +} diff --git a/android/src/main/kotlin/com/smileidentity/flutter/generated/Messages.g.kt b/android/src/main/kotlin/com/smileidentity/flutter/generated/Messages.g.kt new file mode 100644 index 00000000..00d8fa63 --- /dev/null +++ b/android/src/main/kotlin/com/smileidentity/flutter/generated/Messages.g.kt @@ -0,0 +1,470 @@ +// Autogenerated from Pigeon (v10.1.6), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + if (exception is FlutterError) { + return listOf( + exception.code, + exception.message, + exception.details, + ) + } else { + return listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception), + ) + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError( + val code: String, + override val message: String? = null, + val details: Any? = null, +) : Throwable() + +enum class FlutterJobType(val raw: Int) { + ENHANCEDKYC(0), + DOCUMENTVERIFICATION(1), + ; + + companion object { + fun ofRaw(raw: Int): FlutterJobType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * Custom values specific to partners can be placed in [extras] + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class FlutterPartnerParams( + val jobType: FlutterJobType? = null, + val jobId: String, + val userId: String, + val extras: Map? = null, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): FlutterPartnerParams { + val jobType: FlutterJobType? = (list[0] as Int?)?.let { + FlutterJobType.ofRaw(it) + } + val jobId = list[1] as String + val userId = list[2] as String + val extras = list[3] as Map? + return FlutterPartnerParams(jobType, jobId, userId, extras) + } + } + fun toList(): List { + return listOf( + jobType?.raw, + jobId, + userId, + extras, + ) + } +} + +/** + * The Auth Smile request. Auth Smile serves multiple purposes: + * + * - It is used to fetch the signature needed for subsequent API requests + * - It indicates the type of job that will being performed + * - It is used to fetch consent information for the partner + * + * [jobType] The type of job that will be performed + * [country] The country code of the country where the job is being performed. This value is + * required in order to get back consent information for the partner + * [idType] The type of ID that will be used for the job. This value is required in order to + * get back consent information for the partner + * [updateEnrolledImage] Whether or not the enrolled image should be updated with image + * submitted for this job + * [jobId] The job ID to associate with the job. Most often, this will correspond to a unique + * Job ID within your own system. If not provided, a random job ID will be generated + * [userId] The user ID to associate with the job. Most often, this will correspond to a unique + * User ID within your own system. If not provided, a random user ID will be generated + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class FlutterAuthenticationRequest( + val jobType: FlutterJobType, + val country: String? = null, + val idType: String? = null, + val updateEnrolledImage: Boolean? = null, + val jobId: String? = null, + val userId: String? = null, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): FlutterAuthenticationRequest { + val jobType = FlutterJobType.ofRaw(list[0] as Int)!! + val country = list[1] as String? + val idType = list[2] as String? + val updateEnrolledImage = list[3] as Boolean? + val jobId = list[4] as String? + val userId = list[5] as String? + return FlutterAuthenticationRequest( + jobType, + country, + idType, + updateEnrolledImage, + jobId, + userId, + ) + } + } + fun toList(): List { + return listOf( + jobType.raw, + country, + idType, + updateEnrolledImage, + jobId, + userId, + ) + } +} + +/** + * [consentInfo] is only populated when a country and ID type are provided in the + * [FlutterAuthenticationRequest]. To get information about *all* countries and ID types instead, + * [SmileIDService.getProductsConfig] + * + * [timestamp] is *not* a [DateTime] because technically, any arbitrary value could have been + * passed to it. This applies to all other timestamp fields in the SDK. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class FlutterAuthenticationResponse( + val success: Boolean, + val signature: String, + val timestamp: String, + val partnerParams: FlutterPartnerParams, + val callbackUrl: String? = null, + val consentInfo: FlutterConsentInfo? = null, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): FlutterAuthenticationResponse { + val success = list[0] as Boolean + val signature = list[1] as String + val timestamp = list[2] as String + val partnerParams = FlutterPartnerParams.fromList(list[3] as List) + val callbackUrl = list[4] as String? + val consentInfo: FlutterConsentInfo? = (list[5] as List?)?.let { + FlutterConsentInfo.fromList(it) + } + return FlutterAuthenticationResponse( + success, + signature, + timestamp, + partnerParams, + callbackUrl, + consentInfo, + ) + } + } + fun toList(): List { + return listOf( + success, + signature, + timestamp, + partnerParams.toList(), + callbackUrl, + consentInfo?.toList(), + ) + } +} + +/** + * [canAccess] Whether or not the ID type is enabled for the partner + * [consentRequired] Whether or not consent is required for the ID type + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class FlutterConsentInfo( + val canAccess: Boolean, + val consentRequired: Boolean, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): FlutterConsentInfo { + val canAccess = list[0] as Boolean + val consentRequired = list[1] as Boolean + return FlutterConsentInfo(canAccess, consentRequired) + } + } + fun toList(): List { + return listOf( + canAccess, + consentRequired, + ) + } +} + +/** + * [timestamp] is *not* a [DateTime] because technically, any arbitrary value could have been + * passed to it. This applies to all other timestamp fields in the SDK. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class FlutterEnhancedKycRequest( + val country: String, + val idType: String, + val idNumber: String, + val firstName: String? = null, + val middleName: String? = null, + val lastName: String? = null, + val dob: String? = null, + val phoneNumber: String? = null, + val bankCode: String? = null, + val callbackUrl: String? = null, + val partnerParams: FlutterPartnerParams, + val timestamp: String, + val signature: String, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): FlutterEnhancedKycRequest { + val country = list[0] as String + val idType = list[1] as String + val idNumber = list[2] as String + val firstName = list[3] as String? + val middleName = list[4] as String? + val lastName = list[5] as String? + val dob = list[6] as String? + val phoneNumber = list[7] as String? + val bankCode = list[8] as String? + val callbackUrl = list[9] as String? + val partnerParams = FlutterPartnerParams.fromList(list[10] as List) + val timestamp = list[11] as String + val signature = list[12] as String + return FlutterEnhancedKycRequest(country, idType, idNumber, firstName, middleName, lastName, dob, phoneNumber, bankCode, callbackUrl, partnerParams, timestamp, signature) + } + } + fun toList(): List { + return listOf( + country, + idType, + idNumber, + firstName, + middleName, + lastName, + dob, + phoneNumber, + bankCode, + callbackUrl, + partnerParams.toList(), + timestamp, + signature, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class FlutterEnhancedKycAsyncResponse( + val success: Boolean, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): FlutterEnhancedKycAsyncResponse { + val success = list[0] as Boolean + return FlutterEnhancedKycAsyncResponse(success) + } + } + fun toList(): List { + return listOf( + success, + ) + } +} + +@Suppress("UNCHECKED_CAST") +private object SmileIDApiCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 128.toByte() -> { + return (readValue(buffer) as? List)?.let { + FlutterAuthenticationRequest.fromList(it) + } + } + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + FlutterAuthenticationResponse.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + FlutterConsentInfo.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + FlutterEnhancedKycAsyncResponse.fromList(it) + } + } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { + FlutterEnhancedKycRequest.fromList(it) + } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + FlutterPartnerParams.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is FlutterAuthenticationRequest -> { + stream.write(128) + writeValue(stream, value.toList()) + } + is FlutterAuthenticationResponse -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is FlutterConsentInfo -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is FlutterEnhancedKycAsyncResponse -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is FlutterEnhancedKycRequest -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is FlutterPartnerParams -> { + stream.write(133) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface SmileIDApi { + fun initialize() + fun authenticate( + request: FlutterAuthenticationRequest, + callback: (Result) -> Unit, + ) + fun doEnhancedKycAsync( + request: FlutterEnhancedKycRequest, + callback: (Result) -> Unit, + ) + + companion object { + /** The codec used by SmileIDApi. */ + val codec: MessageCodec by lazy { + SmileIDApiCodec + } + + /** Sets up an instance of `SmileIDApi` to handle messages through the `binaryMessenger`. */ + @Suppress("UNCHECKED_CAST") + fun setUp(binaryMessenger: BinaryMessenger, api: SmileIDApi?) { + run { + val channel = BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.smileid.SmileIDApi.initialize", + codec, + ) + if (api != null) { + channel.setMessageHandler { _, reply -> + var wrapped: List + try { + api.initialize() + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.smileid.SmileIDApi.authenticate", + codec, + ) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestArg = args[0] as FlutterAuthenticationRequest + api.authenticate(requestArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.smileid.SmileIDApi.doEnhancedKycAsync", + codec, + ) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestArg = args[0] as FlutterEnhancedKycRequest + + api.doEnhancedKycAsync(requestArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/android/src/test/kotlin/com/smileidentity/flutter/SmileIDPluginTest.kt b/android/src/test/kotlin/com/smileidentity/flutter/SmileIDPluginTest.kt index 55389f01..33335ad1 100644 --- a/android/src/test/kotlin/com/smileidentity/flutter/SmileIDPluginTest.kt +++ b/android/src/test/kotlin/com/smileidentity/flutter/SmileIDPluginTest.kt @@ -22,7 +22,6 @@ internal class SmileIDPluginTest { @Test fun `when we call authenticate and pass a request object, we get a successful callback`() { - val request = mockk() val callback = mockk<(Result) -> Unit>() val api = mockk() @@ -30,28 +29,28 @@ internal class SmileIDPluginTest { coEvery { api.authenticate( request = request, - callback = callback + callback = callback, ) } returns Unit api.authenticate( request = request, - callback = callback + callback = callback, ) coVerify { api.authenticate( request = request, - callback = callback + callback = callback, ) } confirmVerified(api) } + // ktlint-disable max-line-length @Test fun `when we call doEnhancedKycAsync and pass a request object, we get a successful callback`() { - val request = mockk() val callback = mockk<(Result) -> Unit>() val api = mockk() @@ -59,22 +58,22 @@ internal class SmileIDPluginTest { coEvery { api.doEnhancedKycAsync( request = request, - callback = callback + callback = callback, ) } returns Unit api.doEnhancedKycAsync( request = request, - callback = callback + callback = callback, ) coVerify { api.doEnhancedKycAsync( request = request, - callback = callback + callback = callback, ) } confirmVerified(api) } -} \ No newline at end of file +} diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 61b6c4de..e0e9e732 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -27,3 +27,5 @@ linter: # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options + + diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index b04541ce..0d96c123 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -24,6 +24,7 @@ if (flutterVersionName == null) { apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" +apply plugin: "org.jlleitschuh.gradle.ktlint" android { namespace "com.smileidentity.flutter.sample" diff --git a/example/android/app/src/main/kotlin/com/smileidentity/flutter/sample/MainActivity.kt b/example/android/app/src/main/kotlin/com/smileidentity/flutter/sample/MainActivity.kt index c8ea1778..4988135b 100644 --- a/example/android/app/src/main/kotlin/com/smileidentity/flutter/sample/MainActivity.kt +++ b/example/android/app/src/main/kotlin/com/smileidentity/flutter/sample/MainActivity.kt @@ -1,5 +1,5 @@ package com.smileidentity.flutter.sample -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity: FlutterActivity() +class MainActivity : FlutterFragmentActivity() diff --git a/example/android/build.gradle b/example/android/build.gradle index e5d68401..949ddd0a 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,19 +1,20 @@ buildscript { - ext.kotlin_version = "1.9.0" + ext.kotlin_version = "1.9.10" repositories { google() mavenCentral() + maven { url "https://plugins.gradle.org/m2/" } } dependencies { - classpath 'com.android.tools.build:gradle:8.1.0' + classpath 'com.android.tools.build:gradle:8.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jlleitschuh.gradle:ktlint-gradle:11.6.0" } } allprojects { repositories { - maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } google() mavenCentral() } diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart index 7b051afd..d53d2ae6 100644 --- a/example/integration_test/plugin_integration_test.dart +++ b/example/integration_test/plugin_integration_test.dart @@ -6,19 +6,8 @@ // For more information about Flutter integration tests, please see // https://docs.flutter.dev/cookbook/testing/integration/introduction - -import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:smile_id/smile_id.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('getPlatformVersion test', (WidgetTester tester) async { - final SmileID plugin = SmileID(); - final String? version = await plugin.getPlatformVersion(); - // The version string depends on the host platform running the test, so - // just assert that some non-empty string is returned. - expect(version?.isNotEmpty, true); - }); } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index f40d0647..18e895ff 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,10 +2,10 @@ PODS: - Flutter (1.0.0) - integration_test (0.0.1): - Flutter - - smile_id (10.0.0-beta06): + - smile_id (10.0.0-beta09): - Flutter - - SmileID (= 10.0.0-beta06) - - SmileID (10.0.0-beta06): + - SmileID (= 10.0.0-beta09) + - SmileID (10.0.0-beta09): - Zip (~> 2.1.0) - Zip (2.1.2) @@ -30,10 +30,10 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 integration_test: 13825b8a9334a850581300559b8839134b124670 - smile_id: b8ca41650880b5ab85addb0e485131906df9430e - SmileID: b102f1f2e2ab35601d89ea618ef26c27b4489421 + smile_id: 280d6479aab2ee1196acfd59d5349cfb216f04ac + SmileID: b1ce730e8596defe821fbd510b8e4f714885284d Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 PODFILE CHECKSUM: 929954fb8941cef06249e96bd1516fd2a22ed7a5 -COCOAPODS: 1.12.1 +COCOAPODS: 1.13.0 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 2ca7ab16..9c1e7a67 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -217,6 +217,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1300; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -470,6 +471,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 99P7YGX9Q6; ENABLE_BITCODE = NO; @@ -481,6 +484,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.smileidentity.smileidExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 8f54b0f0..23139d25 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,10 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + This will be used to verify your identity + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -43,9 +49,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/lib/main.dart b/example/lib/main.dart index 3ca526b5..98c1907b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,6 +3,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:smile_id/messages.g.dart'; import 'package:smile_id/smile_id.dart'; +import 'package:smile_id/smile_id_document_verification.dart'; +import 'package:smile_id/smile_id_smart_selfie_enrollment.dart'; +import 'package:smile_id/smile_id_smart_selfie_authentication.dart'; + +// ignore_for_file: avoid_print void main() { runApp(const MyApp()); @@ -29,47 +34,162 @@ class _MyAppState extends State { // message was in flight, we want to discard the reply rather than calling // setState to update our non-existent appearance. if (!mounted) return; + SmileID.initialize(); } @override Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar( - title: const Text('Smile ID'), - ), - body: Center( - child: Column( - children: [ - ElevatedButton( - child: const Text("Enhanced KYC (Async)"), - onPressed: () { - SmileID.initialize(); - var userId = ""; - SmileID.authenticate(FlutterAuthenticationRequest( - jobType: FlutterJobType.enhancedKyc, - userId: userId, - )).then((authResponse) => { - SmileID.doEnhancedKycAsync(FlutterEnhancedKycRequest( - country: "GH", - idType: "DRIVERS_LICENSE", - idNumber: "B0000000", - callbackUrl: "https://webhook.site/a3d19f24-769a-46f2-997c-d186c3ae70ea", - partnerParams: FlutterPartnerParams( - jobType: FlutterJobType.enhancedKyc, - jobId: userId, - userId: userId, - ), - timestamp: authResponse!.timestamp, - signature: authResponse.signature - )) - }, onError: (error) => {print("error: $error")}); - } - ) - ], - ) - ), + return const MaterialApp( + title: "Smile ID", + // MainContent requires its own BuildContext for Navigator to work, hence it is defined as its + // own widget + home: MainContent(), + ); + } +} + +class MyScaffold extends StatelessWidget { + final Widget body; + const MyScaffold({super.key, required this.body}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text("Smile ID"), ), + body: body, + ); + } +} + +class MainContent extends StatelessWidget { + const MainContent({super.key}); + + @override + Widget build(BuildContext context) { + return MyScaffold( + body: Center( + child: Column( + children: [ + enhancedKycAsyncButton(), + documentVerificationButton(context), + smartSelfieEnrollmentButton(context), + smartSelfieAuthenticationButton(context) + ], + ))); + } + + Widget enhancedKycAsyncButton() { + return ElevatedButton( + child: const Text("Enhanced KYC (Async)"), + onPressed: () { + SmileID.initialize(); + var userId = ""; + SmileID.authenticate(FlutterAuthenticationRequest( + jobType: FlutterJobType.enhancedKyc, + userId: userId, + )).then((authResponse) => { + SmileID.doEnhancedKycAsync(FlutterEnhancedKycRequest( + country: "GH", + idType: "DRIVERS_LICENSE", + idNumber: "B0000000", + callbackUrl: "https://webhook.site/a3d19f24-769a-46f2-997c-d186c3ae70ea", + partnerParams: FlutterPartnerParams( + jobType: FlutterJobType.enhancedKyc, + jobId: userId, + userId: userId, + ), + timestamp: authResponse!.timestamp, + signature: authResponse.signature + )) + }, onError: (error) => {print("error: $error")}); + } + ); + } + + Widget documentVerificationButton(BuildContext context) { + return ElevatedButton( + child: const Text("Document Verification"), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => MyScaffold( + body: SmileIDDocumentVerification( + countryCode: "GH", + documentType: "DRIVERS_LICENSE", + allowGalleryUpload: true, + onSuccess: (String? result) { + // Your success handling logic + final snackBar = SnackBar(content: Text("Success: $result")); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + Navigator.of(context).pop(); + }, + onError: (String errorMessage) { + // Your error handling logic + final snackBar = SnackBar(content: Text("Error: $errorMessage")); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + Navigator.of(context).pop(); + }, + )), + ), + ); + }, + ); + } + + Widget smartSelfieEnrollmentButton(BuildContext context) { + return ElevatedButton( + child: const Text("SmartSelfie Enrollment"), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => MyScaffold( + body: SmileIDSmartSelfieEnrollment( + onSuccess: (String? result) { + // Your success handling logic + final snackBar = SnackBar(content: Text("Success: $result")); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + Navigator.of(context).pop(); + }, + onError: (String errorMessage) { + // Your error handling logic + final snackBar = SnackBar(content: Text("Error: $errorMessage")); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + Navigator.of(context).pop(); + }, + )), + ), + ); + }, + ); + } + + Widget smartSelfieAuthenticationButton(BuildContext context) { + return ElevatedButton( + child: const Text("SmartSelfie Authentication"), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => MyScaffold( + body: SmileIDSmartSelfieAuthentication( + onSuccess: (String? result) { + // Your success handling logic + final snackBar = SnackBar(content: Text("Success: $result")); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + Navigator.of(context).pop(); + }, + onError: (String errorMessage) { + // Your error handling logic + final snackBar = SnackBar(content: Text("Error: $errorMessage")); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + Navigator.of(context).pop(); + }, + )), + ), + ); + }, ); } } diff --git a/example/pubspec.lock b/example/pubspec.lock index d2694adf..67acc221 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -79,10 +79,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "2118df84ef0c3ca93f96123a616ae8540879991b8b57af2f81b76a7ada49b2a4" + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" flutter_test: dependency: "direct dev" description: flutter diff --git a/example/pubspec.yaml b/example/pubspec.yaml index cfd6eb91..2abec66f 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: path: ../ dev_dependencies: - flutter_lints: ^2.0.0 + flutter_lints: ^2.0.3 flutter_test: sdk: flutter integration_test: diff --git a/ios/Classes/Mapper.swift b/ios/Classes/Mapper.swift index 2db35152..bd7cb8b0 100644 --- a/ios/Classes/Mapper.swift +++ b/ios/Classes/Mapper.swift @@ -2,20 +2,22 @@ import SmileID extension FlutterEnhancedKycRequest { func toRequest() -> EnhancedKycRequest { - EnhancedKycRequest(country: country, - idType: idType, - idNumber: idNumber, - firstName: firstName, - middleName: middleName, - lastName: lastName, - dob: dob, - phoneNumber: phoneNumber, - bankCode: bankCode, - callbackUrl: callbackUrl, - partnerParams: partnerParams.toRequest(), - sourceSdk: "ios (flutter)", - timestamp: timestamp, - signature: signature) + EnhancedKycRequest( + country: country, + idType: idType, + idNumber: idNumber, + firstName: firstName, + middleName: middleName, + lastName: lastName, + dob: dob, + phoneNumber: phoneNumber, + bankCode: bankCode, + callbackUrl: callbackUrl, + partnerParams: partnerParams.toRequest(), + sourceSdk: "ios (flutter)", + timestamp: timestamp, + signature: signature + ) } } @@ -33,38 +35,46 @@ extension EnhancedKycAsyncResponse { extension FlutterPartnerParams { func toRequest() -> PartnerParams { - PartnerParams(jobId: jobId, - userId: userId, - jobType: jobType!.toRequest()) + PartnerParams( + jobId: jobId, + userId: userId, + jobType: jobType!.toRequest() + ) } } extension FlutterAuthenticationRequest { func toRequest() -> AuthenticationRequest { let mappedJobType = jobType.toRequest() - return AuthenticationRequest(jobType: mappedJobType, - enrollment: mappedJobType == .smartSelfieEnrollment, - updateEnrolledImage: updateEnrolledImage, - jobId: jobId, - userId: userId) + return AuthenticationRequest( + jobType: mappedJobType, + enrollment: mappedJobType == .smartSelfieEnrollment, + updateEnrolledImage: updateEnrolledImage, + jobId: jobId, + userId: userId + ) } } extension AuthenticationResponse { func toResponse() -> FlutterAuthenticationResponse { - FlutterAuthenticationResponse(success: success, - signature: signature, - timestamp: timestamp, - partnerParams: partnerParams.toFlutterPartnerParams()) + FlutterAuthenticationResponse( + success: success, + signature: signature, + timestamp: timestamp, + partnerParams: partnerParams.toFlutterPartnerParams() + ) } } extension PartnerParams { func toFlutterPartnerParams() -> FlutterPartnerParams { - FlutterPartnerParams(jobType: FlutterJobType(rawValue: jobType.rawValue), - jobId: jobId, - userId: userId, - extras: [:]) + FlutterPartnerParams( + jobType: jobType?.toResponse(), + jobId: jobId, + userId: userId, + extras: [:] + ) } } @@ -73,7 +83,8 @@ extension FlutterJobType { switch (self) { case .enhancedKyc: return JobType.enhancedKyc - default: fatalError("Not yet supported") + case .documentVerification: + return JobType.documentVerification } } } @@ -83,6 +94,8 @@ extension JobType { switch (self) { case .enhancedKyc: return FlutterJobType.enhancedKyc + case .documentVerification: + return FlutterJobType.documentVerification default: fatalError("Not yet supported") } } diff --git a/ios/Classes/Messages.g.swift b/ios/Classes/Messages.g.swift index 3a28aa01..b4be4b14 100644 --- a/ios/Classes/Messages.g.swift +++ b/ios/Classes/Messages.g.swift @@ -36,6 +36,7 @@ private func nilOrValue(_ value: Any?) -> T? { enum FlutterJobType: Int { case enhancedKyc = 0 + case documentVerification = 1 } /// Custom values specific to partners can be placed in [extras] diff --git a/ios/Classes/SmileIDDocumentVerification.swift b/ios/Classes/SmileIDDocumentVerification.swift new file mode 100644 index 00000000..80e3cac5 --- /dev/null +++ b/ios/Classes/SmileIDDocumentVerification.swift @@ -0,0 +1,101 @@ +import Flutter +import UIKit +import SmileID +import SwiftUI + +class SmileIDDocumentVerification : NSObject, FlutterPlatformView, DocumentCaptureResultDelegate { + private var _view: UIView + private var _channel: FlutterMethodChannel + private var _childViewController: UIViewController? + + static let VIEW_TYPE_ID = "SmileIDDocumentVerification" + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: [String: Any?], + binaryMessenger messenger: FlutterBinaryMessenger + ) { + _view = UIView() + _channel = FlutterMethodChannel( + name: "\(SmileIDDocumentVerification.VIEW_TYPE_ID)_\(viewId)", + binaryMessenger: messenger + ) + _childViewController = nil + super.init() + let bypassSelfieCaptureWithFile = (args["bypassSelfieCaptureWithFile"] as? String) + .flatMap { URL(string: $0) } + let screen = SmileID.documentVerificationScreen( + userId: args["userId"] as? String ?? "user-\(UUID().uuidString)", + jobId: args["jobId"] as? String ?? "job-\(UUID().uuidString)", + countryCode: args["countryCode"] as! String, + documentType: args["documentType"] as? String, + idAspectRatio: args["idAspectRatio"] as? Double, + bypassSelfieCaptureWithFile: bypassSelfieCaptureWithFile, + captureBothSides: args["captureBothSides"] as? Bool ?? true, + allowGalleryUpload: args["allowGalleryUpload"] as? Bool ?? false, + showInstructions: args["showInstructions"] as? Bool ?? true, + showAttribution: args["showAttribution"] as? Bool ?? true, + delegate: self + ) + let childViewController = UIHostingController(rootView: screen) + + childViewController.view.frame = frame + childViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + _view.addSubview(childViewController.view) + let rootViewController = UIApplication.shared.windows.first?.rootViewController + rootViewController?.addChild(childViewController) + _childViewController = childViewController + } + + func view() -> UIView { + return _view + } + + func didSucceed( + selfie: URL, + documentFrontImage: URL, + documentBackImage: URL?, + jobStatusResponse: JobStatusResponse + ) { + _childViewController?.removeFromParent() + let encoder = JSONEncoder() + let documentBackFileJson = documentBackImage.map{ "\"\($0.absoluteString)\"" } ?? "null" + _channel.invokeMethod("onSuccess", arguments: """ + "selfieFile": "\(selfie.absoluteString)", + "documentFrontFile": "\(documentFrontImage.absoluteString)", + "documentBackFile": \(documentBackFileJson), + "jobStatusResponse": \(try! encoder.encode(jobStatusResponse)) + """) + } + + func didError(error: Error) { + print("[Smile ID] An error occurred - \(error.localizedDescription)") + _channel.invokeMethod("onError", arguments: error.localizedDescription) + } + + class Factory : NSObject, FlutterPlatformViewFactory { + private var messenger: FlutterBinaryMessenger + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + return SmileIDDocumentVerification( + frame: frame, + viewIdentifier: viewId, + arguments: args as! [String: Any?], + binaryMessenger: messenger + ) + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } + } +} diff --git a/ios/Classes/SmileIDPlugin.swift b/ios/Classes/SmileIDPlugin.swift index 0f12f7a2..c01943b5 100644 --- a/ios/Classes/SmileIDPlugin.swift +++ b/ios/Classes/SmileIDPlugin.swift @@ -4,14 +4,45 @@ import SmileID import Combine public class SmileIDPlugin: NSObject, FlutterPlugin, SmileIDApi { - private var subscribers = Set() - public static func register(with registrar: FlutterPluginRegistrar) { - let messenger: FlutterBinaryMessenger = registrar.messenger() - let api: SmileIDApi & NSObjectProtocol = SmileIDPlugin() - SmileIDApiSetup.setUp(binaryMessenger: messenger, api: api) - } + private var subscribers = Set() + public static func register(with registrar: FlutterPluginRegistrar) { + let messenger: FlutterBinaryMessenger = registrar.messenger() + let api: SmileIDApi & NSObjectProtocol = SmileIDPlugin() + SmileIDApiSetup.setUp(binaryMessenger: messenger, api: api) - func authenticate(request: FlutterAuthenticationRequest, completion: @escaping (Result) -> Void) { + let documentVerificationFactory = SmileIDDocumentVerification.Factory( + messenger: registrar.messenger() + ) + registrar.register( + documentVerificationFactory, + withId: SmileIDDocumentVerification.VIEW_TYPE_ID + ) + + let smartSelfieEnrollmentFactory = SmileIDSmartSelfieEnrollment.Factory( + messenger: registrar.messenger() + ) + registrar.register( + smartSelfieEnrollmentFactory, + withId: SmileIDSmartSelfieEnrollment.VIEW_TYPE_ID + ) + + let smartSelfieAuthenticationFactory = SmileIDSmartSelfieAuthentication.Factory( + messenger: registrar.messenger() + ) + registrar.register( + smartSelfieAuthenticationFactory, + withId: SmileIDSmartSelfieAuthentication.VIEW_TYPE_ID + ) + } + + func initialize() { + SmileID.initialize() + } + + func authenticate( + request: FlutterAuthenticationRequest, + completion: @escaping (Result + ) -> Void) { SmileID.api.authenticate(request: request.toRequest()) .sink(receiveCompletion: { status in switch status { @@ -25,22 +56,20 @@ public class SmileIDPlugin: NSObject, FlutterPlugin, SmileIDApi { }).store(in: &subscribers) } - func initialize() { - SmileID.initialize() - } - - func doEnhancedKycAsync(request: FlutterEnhancedKycRequest, - completion: @escaping (Result) -> Void) { - SmileID.api.doEnhancedKycAsync(request: request.toRequest()) - .sink(receiveCompletion: { status in - switch status { - case .failure(let error): - completion(.failure(error)) - default: - break - } + func doEnhancedKycAsync( + request: FlutterEnhancedKycRequest, + completion: @escaping (Result + ) -> Void) { + SmileID.api.doEnhancedKycAsync(request: request.toRequest()) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + completion(.failure(error)) + default: + break + } }, receiveValue: { response in completion(.success(response.toFlutterResponse())) }).store(in: &subscribers) - } + } } diff --git a/ios/Classes/SmileIDSmartSelfieAuthentication.swift b/ios/Classes/SmileIDSmartSelfieAuthentication.swift new file mode 100644 index 00000000..2ce26457 --- /dev/null +++ b/ios/Classes/SmileIDSmartSelfieAuthentication.swift @@ -0,0 +1,88 @@ +import Flutter +import UIKit +import SmileID +import SwiftUI + +class SmileIDSmartSelfieAuthentication : NSObject, FlutterPlatformView, SmartSelfieResultDelegate { + private var _view: UIView + private var _channel: FlutterMethodChannel + private var _childViewController: UIViewController? + + static let VIEW_TYPE_ID = "SmileIDSmartSelfieAuthentication" + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: [String: Any?], + binaryMessenger messenger: FlutterBinaryMessenger + ) { + _view = UIView() + _channel = FlutterMethodChannel( + name: "\(SmileIDSmartSelfieAuthentication.VIEW_TYPE_ID)_\(viewId)", + binaryMessenger: messenger + ) + _childViewController = nil + super.init() + let screen = SmileID.smartSelfieEnrollmentScreen( + userId: args["userId"] as? String ?? "user-\(UUID().uuidString)", + jobId: args["jobId"] as? String ?? "job-\(UUID().uuidString)", + allowAgentMode: args["allowAgentMode"] as? Bool ?? false, + showAttribution: args["showAttribution"] as? Bool ?? true, + showInstructions: args["showInstructions"] as? Bool ?? true, + delegate: self + ) + let childViewController = UIHostingController(rootView: screen) + + childViewController.view.frame = frame + childViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + _view.addSubview(childViewController.view) + let rootViewController = UIApplication.shared.windows.first?.rootViewController + rootViewController?.addChild(childViewController) + _childViewController = childViewController + } + + func view() -> UIView { + return _view + } + + func didSucceed(selfieImage: URL, livenessImages: [URL], jobStatusResponse: JobStatusResponse) { + _childViewController?.removeFromParent() + let encoder = JSONEncoder() + _channel.invokeMethod("onSuccess", arguments: """ + "selfieFile": "\(selfieImage.absoluteString)", + "livenessImages": "\(livenessImages.map { $0.absoluteString })", + "jobStatusResponse": \(try! encoder.encode(jobStatusResponse)) + """) + } + + func didError(error: Error) { + print("[Smile ID] An error occurred - \(error.localizedDescription)") + _channel.invokeMethod("onError", arguments: error.localizedDescription) + } + + + class Factory : NSObject, FlutterPlatformViewFactory { + private var messenger: FlutterBinaryMessenger + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + return SmileIDSmartSelfieAuthentication( + frame: frame, + viewIdentifier: viewId, + arguments: args as! [String: Any?], + binaryMessenger: messenger + ) + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } + } +} diff --git a/ios/Classes/SmileIDSmartSelfieEnrollment.swift b/ios/Classes/SmileIDSmartSelfieEnrollment.swift new file mode 100644 index 00000000..3b5746a4 --- /dev/null +++ b/ios/Classes/SmileIDSmartSelfieEnrollment.swift @@ -0,0 +1,87 @@ +import Flutter +import UIKit +import SmileID +import SwiftUI + +class SmileIDSmartSelfieEnrollment : NSObject, FlutterPlatformView, SmartSelfieResultDelegate { + private var _view: UIView + private var _channel: FlutterMethodChannel + private var _childViewController: UIViewController? + + static let VIEW_TYPE_ID = "SmileIDSmartSelfieEnrollment" + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: [String: Any?], + binaryMessenger messenger: FlutterBinaryMessenger + ) { + _view = UIView() + _channel = FlutterMethodChannel( + name: "\(SmileIDSmartSelfieEnrollment.VIEW_TYPE_ID)_\(viewId)", + binaryMessenger: messenger + ) + _childViewController = nil + super.init() + let screen = SmileID.smartSelfieEnrollmentScreen( + userId: args["userId"] as? String ?? "user-\(UUID().uuidString)", + jobId: args["jobId"] as? String ?? "job-\(UUID().uuidString)", + allowAgentMode: args["allowAgentMode"] as? Bool ?? false, + showAttribution: args["showAttribution"] as? Bool ?? true, + showInstructions: args["showInstructions"] as? Bool ?? true, + delegate: self + ) + let childViewController = UIHostingController(rootView: screen) + + childViewController.view.frame = frame + childViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + _view.addSubview(childViewController.view) + let rootViewController = UIApplication.shared.windows.first?.rootViewController + rootViewController?.addChild(childViewController) + _childViewController = childViewController + } + + func view() -> UIView { + return _view + } + + func didSucceed(selfieImage: URL, livenessImages: [URL], jobStatusResponse: JobStatusResponse) { + _childViewController?.removeFromParent() + let encoder = JSONEncoder() + _channel.invokeMethod("onSuccess", arguments: """ + "selfieFile": "\(selfieImage.absoluteString)", + "livenessImages": "\(livenessImages.map{ $0.absoluteString })", + "jobStatusResponse": \(try! encoder.encode(jobStatusResponse)) + """) + } + + func didError(error: Error) { + print("[Smile ID] An error occurred - \(error.localizedDescription)") + _channel.invokeMethod("onError", arguments: error.localizedDescription) + } + + class Factory : NSObject, FlutterPlatformViewFactory { + private var messenger: FlutterBinaryMessenger + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + return SmileIDSmartSelfieEnrollment( + frame: frame, + viewIdentifier: viewId, + arguments: args as! [String: Any?], + binaryMessenger: messenger + ) + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } + } +} diff --git a/ios/smile_id.podspec b/ios/smile_id.podspec index 17ed21b2..934e136a 100644 --- a/ios/smile_id.podspec +++ b/ios/smile_id.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = 'smile_id' # NB! Keep this version in sync with the Native iOS SDK version - s.version = '10.0.0-beta06' + s.version = '10.0.0-beta09' s.summary = 'Official Smile ID SDK for Flutter' s.description = <<-DESC A new Flutter project. @@ -15,7 +15,7 @@ A new Flutter project. s.source_files = 'Classes/**/*' s.dependency 'Flutter' # NB! Update the s.version above when changing this version - s.dependency 'SmileID' , '10.0.0-beta06' + s.dependency 'SmileID' , '10.0.0-beta09' s.platform = :ios, '13.0' # Flutter.framework does not contain a i386 slice. diff --git a/lib/messages.g.dart b/lib/messages.g.dart index 4e5e4206..b0708fe6 100644 --- a/lib/messages.g.dart +++ b/lib/messages.g.dart @@ -10,6 +10,7 @@ import 'package:flutter/services.dart'; enum FlutterJobType { enhancedKyc, + documentVerification, } /// Custom values specific to partners can be placed in [extras] diff --git a/lib/smile_id.dart b/lib/smile_id.dart index b7da628e..b860e099 100644 --- a/lib/smile_id.dart +++ b/lib/smile_id.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'messages.g.dart'; @@ -18,4 +19,6 @@ class SmileID { FlutterEnhancedKycRequest request) { return platformInterface.doEnhancedKycAsync(request); } + +// TODO: move authentication and doEnhancedKycAsync to an "api" object to mirror native API } diff --git a/lib/smile_id_document_verification.dart b/lib/smile_id_document_verification.dart new file mode 100644 index 00000000..d3cb986c --- /dev/null +++ b/lib/smile_id_document_verification.dart @@ -0,0 +1,115 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class SmileIDDocumentVerification extends StatelessWidget { + static const String viewType = "SmileIDDocumentVerification"; + final Map creationParams; + + /// Called when the user successfully completes the document verification flow. The result is a + /// JSON string. + final Function(String) onSuccess; + final Function(String) onError; + + const SmileIDDocumentVerification._({ + required this.creationParams, + required this.onSuccess, + required this.onError, + }); + + factory SmileIDDocumentVerification({ + Key? key, + required String countryCode, + String? documentType, + double? idAspectRatio, + bool captureBothSides = true, + String? bypassSelfieCaptureWithFile, + // userId and jobId can't actually be null in the native SDK but we delegate their creation to + // the native platform code, since that's where the random ID creation happens + String? userId, + String? jobId, + bool showAttribution = true, + bool allowGalleryUpload = false, + bool showInstructions = true, + required Function(String resultJson) onSuccess, + required Function(String errorMessage) onError, + }) { + return SmileIDDocumentVerification._( + onSuccess: onSuccess, + onError: onError, + creationParams: { + "countryCode": countryCode, + "documentType": documentType, + "idAspectRatio": idAspectRatio, + "captureBothSides": captureBothSides, + "bypassSelfieCaptureWithFile": bypassSelfieCaptureWithFile, + "userId": userId, + "jobId": jobId, + "showAttribution": showAttribution, + "allowGalleryUpload": allowGalleryUpload, + "showInstructions": showInstructions, + }, + ); + } + + @override + Widget build(BuildContext context) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return PlatformViewLink( + viewType: viewType, + surfaceFactory: (context, controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: const >{ + Factory(EagerGestureRecognizer.new) + }, + ); + }, + onCreatePlatformView: (params) { + return PlatformViewsService.initExpensiveAndroidView( + id: params.id, + viewType: viewType, + layoutDirection: Directionality.of(context), + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () { + params.onFocusChanged(true); + }, + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..addOnPlatformViewCreatedListener(_onPlatformViewCreated) + ..create(); + }, + ); + case TargetPlatform.iOS: + return UiKitView( + viewType: viewType, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: _onPlatformViewCreated, + ); + default: + throw UnsupportedError("Unsupported platform"); + } + } + + void _onPlatformViewCreated(int id) { + final channel = MethodChannel("${viewType}_$id"); + channel.setMethodCallHandler(_handleMethodCall); + } + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case "onSuccess": + onSuccess(call.arguments); + case "onError": + onError(call.arguments); + default: + throw MissingPluginException(); + } + } +} diff --git a/lib/smile_id_smart_selfie_authentication.dart b/lib/smile_id_smart_selfie_authentication.dart new file mode 100644 index 00000000..28e342f2 --- /dev/null +++ b/lib/smile_id_smart_selfie_authentication.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class SmileIDSmartSelfieAuthentication extends StatelessWidget { + static const String viewType = "SmileIDSmartSelfieAuthentication"; + final Map creationParams; + + /// Called when the user successfully completes the smart selfie enrollment flow. The result is a + /// JSON string. + final Function(String) onSuccess; + final Function(String) onError; + + const SmileIDSmartSelfieAuthentication._({ + required this.creationParams, + required this.onSuccess, + required this.onError, + }); + + factory SmileIDSmartSelfieAuthentication({ + Key? key, + // userId and jobId can't actually be null in the native SDK but we delegate their creation to + // the native platform code, since that's where the random ID creation happens + String? userId, + String? jobId, + bool allowAgentMode = false, + bool showAttribution = true, + required Function(String resultJson) onSuccess, + required Function(String errorMessage) onError, + }) { + return SmileIDSmartSelfieAuthentication._( + onSuccess: onSuccess, + onError: onError, + creationParams: { + "userId": userId, + "jobId": jobId, + "allowAgentMode": allowAgentMode, + "showAttribution": showAttribution, + }, + ); + } + + @override + Widget build(BuildContext context) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return PlatformViewLink( + viewType: viewType, + surfaceFactory: (context, controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: const >{ + Factory(EagerGestureRecognizer.new) + }, + ); + }, + onCreatePlatformView: (params) { + return PlatformViewsService.initExpensiveAndroidView( + id: params.id, + viewType: viewType, + layoutDirection: Directionality.of(context), + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () { + params.onFocusChanged(true); + }, + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..addOnPlatformViewCreatedListener(_onPlatformViewCreated) + ..create(); + }, + ); + case TargetPlatform.iOS: + return UiKitView( + viewType: viewType, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: _onPlatformViewCreated, + ); + default: + throw UnsupportedError("Unsupported platform"); + } + } + + void _onPlatformViewCreated(int id) { + final channel = MethodChannel("${viewType}_$id"); + channel.setMethodCallHandler(_handleMethodCall); + } + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case "onSuccess": + onSuccess(call.arguments); + case "onError": + onError(call.arguments); + default: + throw MissingPluginException(); + } + } +} diff --git a/lib/smile_id_smart_selfie_enrollment.dart b/lib/smile_id_smart_selfie_enrollment.dart new file mode 100644 index 00000000..34c5e85f --- /dev/null +++ b/lib/smile_id_smart_selfie_enrollment.dart @@ -0,0 +1,105 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class SmileIDSmartSelfieEnrollment extends StatelessWidget { + static const String viewType = "SmileIDSmartSelfieEnrollment"; + final Map creationParams; + + /// Called when the user successfully completes the smart selfie enrollment flow. The result is a + /// JSON string. + final Function(String) onSuccess; + final Function(String) onError; + + const SmileIDSmartSelfieEnrollment._({ + required this.creationParams, + required this.onSuccess, + required this.onError, + }); + + factory SmileIDSmartSelfieEnrollment({ + Key? key, + // userId and jobId can't actually be null in the native SDK but we delegate their creation to + // the native platform code, since that's where the random ID creation happens + String? userId, + String? jobId, + bool allowAgentMode = false, + bool showAttribution = true, + bool showInstructions = true, + required Function(String resultJson) onSuccess, + required Function(String errorMessage) onError, + }) { + return SmileIDSmartSelfieEnrollment._( + onSuccess: onSuccess, + onError: onError, + creationParams: { + "userId": userId, + "jobId": jobId, + "allowAgentMode": allowAgentMode, + "showAttribution": showAttribution, + "showInstruction": showInstructions, + }, + ); + } + + @override + Widget build(BuildContext context) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return PlatformViewLink( + viewType: viewType, + surfaceFactory: (context, controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: const >{ + Factory(EagerGestureRecognizer.new) + }, + ); + }, + onCreatePlatformView: (params) { + return PlatformViewsService.initExpensiveAndroidView( + id: params.id, + viewType: viewType, + layoutDirection: Directionality.of(context), + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () { + params.onFocusChanged(true); + }, + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..addOnPlatformViewCreatedListener(_onPlatformViewCreated) + ..create(); + }, + ); + case TargetPlatform.iOS: + return UiKitView( + viewType: viewType, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: _onPlatformViewCreated, + ); + default: + throw UnsupportedError("Unsupported platform"); + } + } + + void _onPlatformViewCreated(int id) { + final channel = MethodChannel("${viewType}_$id"); + channel.setMethodCallHandler(_handleMethodCall); + } + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case "onSuccess": + onSuccess(call.arguments); + case "onError": + onError(call.arguments); + default: + throw MissingPluginException(); + } + } +} diff --git a/pigeon/messages.dart b/pigeon/messages.dart index 7c6b7bad..2c688d89 100644 --- a/pigeon/messages.dart +++ b/pigeon/messages.dart @@ -9,7 +9,7 @@ import 'package:pigeon/pigeon.dart'; swiftOptions: SwiftOptions(), dartPackageName: 'smileid', )) -enum FlutterJobType { enhancedKyc } +enum FlutterJobType { enhancedKyc, documentVerification } /// Custom values specific to partners can be placed in [extras] class FlutterPartnerParams { diff --git a/pubspec.yaml b/pubspec.yaml index dd2048a7..a04f10f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ dev_dependencies: build_runner: ^2.4.6 flutter_test: sdk: flutter - flutter_lints: ^2.0.0 + flutter_lints: ^2.0.3 mockito: ^5.4.2 pigeon: ">=10.1.6 <12.0.0" diff --git a/test/smile_id_test.mocks.dart b/test/smile_id_test.mocks.dart index bd61a2f6..60bc4413 100644 --- a/test/smile_id_test.mocks.dart +++ b/test/smile_id_test.mocks.dart @@ -18,6 +18,7 @@ import 'package:smile_id/messages.g.dart' as _i2; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: override_on_non_overriding_member class _FakeFlutterAuthenticationResponse_0 extends _i1.SmartFake implements _i2.FlutterAuthenticationResponse {