From f2429da2ff4ea5af3bcb29abd9e922038ad3932f Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Wed, 7 Oct 2026 21:19:47 +0000 Subject: [PATCH 1/5] DID-5768: [H11] Flutter SDK: Bank and Location steps pass-through (continuation of DID-5660) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- README.md | 47 ++++++++++++++++++ example/ios/Podfile | 14 +++++- example/ios/Runner.xcodeproj/project.pbxproj | 13 +++++ example/ios/Runner/Info.plist | 7 +++ example/ios/Runner/es.lproj/InfoPlist.strings | 4 ++ example/lib/main.dart | 3 +- test/example_app_config_test.dart | 48 +++++++++++++++++++ test/sdk_flutter_test.dart | 42 ++++++++++++++++ 8 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 example/ios/Runner/es.lproj/InfoPlist.strings create mode 100644 test/example_app_config_test.dart diff --git a/README.md b/README.md index 1db9fc7..84ee068 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ Add the following keys to your app's `Info.plist`: | Microphone | `NSMicrophoneUsageDescription` | Video recording for liveness checks | Yes | | Photo Library | `NSPhotoLibraryUsageDescription` | Upload documents from device gallery | Yes | | NFC | `NFCReaderUsageDescription` | Read NFC chips in passports/ID cards | If using NFC | +| Location | `NSLocationWhenInUseUsageDescription` | Location verification step | If your workflows include Location (see [Location Configuration](#location-configuration-for-the-location-verification-step)) | ```xml NSCameraUsageDescription @@ -197,6 +198,30 @@ Make sure the app's provisioning profile includes the NFC Tag Reading capability This NFC configuration is not needed when `$DiditSdkIosVariant` is `'core'` or `'autodetection'`. +#### Location Configuration (for the Location verification step) + +Only needed when your workflows include a Location step (see [Bank and Location Steps](#bank-and-location-steps)). + +1. **Native Location module.** The step runs on Core Location, which the native SDK ships in its optional `DiditSDK/Location` module, outside every variant, so apps that never run the step do not link location APIs. The released native binaries do not include that module yet. + +2. **Usage strings** in `Info.plist`. `NSLocationTemporaryUsageDescriptionDictionary` is optional: with it, a step that needs a precise position can ask once for precise location when the person granted only an approximate one (iOS 14+). Its purpose key must be exactly `DiditLocationVerification`. + ```xml + NSLocationWhenInUseUsageDescription + Your location is used to confirm where you are for this verification. + NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + + ``` + Translate both in your `InfoPlist.strings` files (iOS looks the precise-location string up by its purpose key), for example `es.lproj/InfoPlist.strings`: + ``` + "NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación."; + "DiditLocationVerification" = "Esta verificación necesita tu ubicación precisa una vez."; + ``` + +3. **App Store privacy label.** Declare Precise Location and Coarse Location (iOS shares the coarse one when the person turns precise location off) for App Functionality, linked to the user and not used for tracking. + ### Android The following permissions are declared in the native SDK's `AndroidManifest.xml` and merged automatically: @@ -207,9 +232,18 @@ The following permissions are declared in the native SDK's `AndroidManifest.xml` | `ACCESS_NETWORK_STATE` | Detect network availability | Yes | | `CAMERA` | Document scanning and face verification | Yes | | `NFC` | Read NFC chips in passports/ID cards | If using NFC | +| `ACCESS_FINE_LOCATION` | Precise position for the Location verification step | Native releases with the Location step | +| `ACCESS_COARSE_LOCATION` | Approximate position for the Location verification step | Native releases with the Location step | Camera and NFC hardware features are declared as optional (`android:required="false"`), so your app can be installed on devices without these features. When `diditSdkAndroidNfcEnabled=false`, the Android NFC permission and feature are not added by the SDK. +The location hardware features (`android.hardware.location`, `android.hardware.location.gps` and `android.hardware.location.network`) are optional too, and on Android 12+ the person can grant precise or approximate location. If your merged manifest has these permissions, declare Approximate location and Precise location as collected data in your app's Google Play Data safety form. If your app never runs Location verification, remove them from your merged manifest (`xmlns:tools="http://schemas.android.com/tools"` on the `` element): + +```xml + + +``` + #### Runtime Permissions The SDK handles Android runtime permission requests automatically. When the user reaches a step that requires camera access: @@ -423,6 +457,15 @@ final sessionToken = await yourBackend.createSession(userId); final result = await DiditSdk.startVerification(sessionToken); ``` +## Bank and Location Steps + +Workflows can include a Bank verification step and a Location verification step. The native SDKs draw both screens, so there is no Dart API to call: start the verification as usual and handle the same [`VerificationResult`](#verification-results), in the language set by [`languageCode`](#languagecode). + +> **Availability:** these steps need a native SDK release that includes them and a Flutter release that pins it. The native SDK this release pins does not include them, so keep Bank and Location out of the workflows you start from the app until the [CHANGELOG](CHANGELOG.md) announces support. + +- **Location:** the SDK explains why it needs the position, asks for location permission, reads the position once while the step is on screen and never tracks it in the background. It needs the iOS setup in [Location Configuration](#location-configuration-for-the-location-verification-step); on Android the permissions are merged from the native SDK (see [Android](#android)). Without the iOS module or usage string, or with the Android permissions removed, the step shows no prompt: it reports the location as unavailable and the workflow's own settings decide what happens next. +- **Bank:** the person picks their bank and approves in the system browser (`SFSafariViewController` on iOS, Custom Tabs on Android), then returns to the SDK, which reads the outcome from Didit. Your app needs no permission, URL scheme or intent filter for it. + ## Verification Results Both `startVerification` and `startVerificationWithWorkflow` return a `Future`. The result is a sealed class — use pattern matching to determine the outcome. @@ -597,6 +640,10 @@ flutter run To run on a real device, open `example/ios/Runner.xcworkspace` in Xcode, configure your signing team, and select your device. +To build the native SDK from a DiditSDK source tree instead (the directory holding `DiditSDK.podspec`, at the version `ios/didit_sdk.podspec` pins), which also adds the `DiditSDK/Location` module, `export DIDIT_SDK_IOS_SOURCE=/path/to/sdk` before `pod install` and `flutter run`. The example app's Info.plist already carries the location usage strings, in English and Spanish. + +The example app starts verification in Hebrew; pick another language with `flutter run --dart-define=DIDIT_LANGUAGE=es`. + ### Android ```bash diff --git a/example/ios/Podfile b/example/ios/Podfile index a0cca42..996f407 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -50,12 +50,22 @@ didit_sdk_ios_podspec = ENV.fetch( 'DIDIT_SDK_IOS_PODSPEC', 'https://raw.githubusercontent.com/didit-protocol/sdk-ios/4.9.1/DiditSDK.podspec' ) +# Optional: the path to a DiditSDK source tree (the directory holding DiditSDK.podspec) +# to build the native SDK from source instead. It also adds the DiditSDK/Location module, +# which the Location verification step needs and the released binaries do not include. +# The tree's DiditSDK version must match the plugin's pin in ios/didit_sdk.podspec. +didit_sdk_ios_source = ENV['DIDIT_SDK_IOS_SOURCE'] target 'Runner' do use_frameworks! - # DiditSDK is not on CocoaPods trunk; pull it from the GitHub-hosted podspec. - pod didit_sdk_ios_pod, :podspec => didit_sdk_ios_podspec + if didit_sdk_ios_source + pod didit_sdk_ios_pod, :path => didit_sdk_ios_source + pod 'DiditSDK/Location', :path => didit_sdk_ios_source + else + # DiditSDK is not on CocoaPods trunk; pull it from the GitHub-hosted podspec. + pod didit_sdk_ios_pod, :podspec => didit_sdk_ios_podspec + end flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index d26a7b2..c9724c9 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4C0C5A012E9F000100D1D1D1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4C0C5A022E9F000100D1D1D1 /* InfoPlist.strings */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 76D6558419DB405620096097 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5EABE474C84BC4A4E8987018 /* Pods_Runner.framework */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; @@ -52,6 +53,7 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3B55FCA806B57C76F9CBBF4D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 4C0C5A032E9F000100D1D1D1 /* es */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 5EABE474C84BC4A4E8987018 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -150,6 +152,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + 4C0C5A022E9F000100D1D1D1 /* InfoPlist.strings */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -240,6 +243,7 @@ knownRegions = ( en, Base, + es, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; @@ -268,6 +272,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 4C0C5A012E9F000100D1D1D1 /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -415,6 +420,14 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + 4C0C5A022E9F000100D1D1D1 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 4C0C5A032E9F000100D1D1D1 /* es */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index d8bb89f..72b3c1a 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -34,6 +34,13 @@ NFC access is required to read the chip in your identity document. NSPhotoLibraryUsageDescription Photo library access is required to upload document images. + NSLocationWhenInUseUsageDescription + Your location is used to confirm where you are for this verification. + NSLocationTemporaryUsageDescriptionDictionary + + DiditLocationVerification + This verification needs your precise location once. + com.apple.developer.nfc.readersession.iso7816.select-identifiers D23300000045737445494420763335 diff --git a/example/ios/Runner/es.lproj/InfoPlist.strings b/example/ios/Runner/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..772dca8 --- /dev/null +++ b/example/ios/Runner/es.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +/* Spanish usage descriptions for the location keys in Info.plist. */ +"NSLocationWhenInUseUsageDescription" = "Tu ubicación se usa para confirmar dónde estás en esta verificación."; +/* Purpose key of NSLocationTemporaryUsageDescriptionDictionary, looked up here by Core Location. */ +"DiditLocationVerification" = "Esta verificación necesita tu ubicación precisa una vez."; diff --git a/example/lib/main.dart b/example/lib/main.dart index 7a19865..92e4573 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -31,8 +31,9 @@ class VerificationScreen extends StatefulWidget { class _VerificationScreenState extends State { static const _demoWalletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; + // Override with `flutter run --dart-define=DIDIT_LANGUAGE=es`. static const _verificationConfig = DiditConfig( - languageCode: 'he', + languageCode: String.fromEnvironment('DIDIT_LANGUAGE', defaultValue: 'he'), loggingEnabled: true, ); diff --git a/test/example_app_config_test.dart b/test/example_app_config_test.dart new file mode 100644 index 0000000..7a00faa --- /dev/null +++ b/test/example_app_config_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +// The example app's iOS location setup. The native Location step reads these +// keys, and iOS looks the precise-location string up by its purpose key. +void main() { + const whenInUseKey = 'NSLocationWhenInUseUsageDescription'; + const purposeKey = 'DiditLocationVerification'; + final plist = File('example/ios/Runner/Info.plist').readAsStringSync(); + + String? plistString(String source, String key) => RegExp( + '${RegExp.escape(key)}\\s*([^<]+)', + ).firstMatch(source)?.group(1); + + test('Info.plist declares the When In Use location string', () { + expect(plistString(plist, whenInUseKey), isNotNull); + }); + + test('Info.plist declares the precise-location purpose key', () { + final dictionary = RegExp( + 'NSLocationTemporaryUsageDescriptionDictionary\\s*(.*?)', + dotAll: true, + ).firstMatch(plist)?.group(1); + + expect(dictionary, isNotNull); + expect(plistString(dictionary!, purposeKey), isNotNull); + }); + + test('es.lproj/InfoPlist.strings translates both location strings', () { + final spanish = File( + 'example/ios/Runner/es.lproj/InfoPlist.strings', + ).readAsStringSync(); + + for (final key in [whenInUseKey, purposeKey]) { + expect(spanish, matches(RegExp('^"$key" = "[^"]+";\$', multiLine: true))); + } + }); + + test('the Xcode project bundles the Spanish InfoPlist.strings', () { + final project = File( + 'example/ios/Runner.xcodeproj/project.pbxproj', + ).readAsStringSync(); + + expect(project, contains('path = es.lproj/InfoPlist.strings;')); + expect(project, contains('InfoPlist.strings in Resources')); + }); +} diff --git a/test/sdk_flutter_test.dart b/test/sdk_flutter_test.dart index 9127358..0915913 100644 --- a/test/sdk_flutter_test.dart +++ b/test/sdk_flutter_test.dart @@ -153,6 +153,22 @@ void main() { expect(platform.lastVerificationConfig?['languageCode'], 'he'); }); + testWidgets('both entry points propagate a Spanish device locale', ( + tester, + ) async { + tester.binding.platformDispatcher.localeTestValue = const Locale( + 'es', + 'ES', + ); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + + await DiditSdk.startVerification('test-token'); + await DiditSdk.startVerificationWithWorkflow('test-workflow'); + + expect(platform.lastVerificationConfig?['languageCode'], 'es'); + expect(platform.lastWorkflowConfig?['languageCode'], 'es'); + }); + test('startVerificationWithWorkflow returns completed result', () async { final result = await DiditSdk.startVerificationWithWorkflow( 'test-workflow', @@ -180,6 +196,32 @@ void main() { expect(failed.error.message, 'The session has expired.'); }); + test('VerificationResult.fromMap keeps an unsupported step message', () { + final result = VerificationResult.fromMap({ + 'type': 'failed', + 'errorType': 'unknown', + 'errorMessage': 'Unsupported verification step', + }); + + expect(result, isA()); + final failed = result as VerificationFailed; + expect(failed.error.type, VerificationErrorType.unknown); + expect(failed.error.message, 'Unsupported verification step'); + }); + + test('VerificationResult.fromMap handles declined result', () { + final result = VerificationResult.fromMap({ + 'type': 'completed', + 'sessionId': 'declined-session', + 'status': 'Declined', + }); + + expect(result, isA()); + final completed = result as VerificationCompleted; + expect(completed.session.sessionId, 'declined-session'); + expect(completed.session.status, VerificationStatus.declined); + }); + test('VerificationResult.fromMap handles cancelled result', () { final result = VerificationResult.fromMap({ 'type': 'cancelled', From 7311a0736a6a54cc2d10c0537d5709d950404cc5 Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Wed, 7 Oct 2026 22:07:09 +0000 Subject: [PATCH 2/5] DID-5768: [H11] Flutter SDK: Bank and Location steps pass-through (continuation of DID-5660) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- .github/workflows/ci.yml | 6 + CONTRIBUTING.md | 4 + android/build.gradle.kts | 3 +- .../sdk/sdk_flutter/SdkFlutterPluginTest.kt | 236 +++++++++++++++++- example/ios/Podfile | 4 +- example/ios/RunnerTests/RunnerTests.swift | 107 +++++++- .../Sources/didit_sdk/SdkFlutterPlugin.swift | 4 +- .../android/build.gradle.kts | 3 +- .../sdk/sdk_flutter/SdkFlutterPluginTest.kt | 236 +++++++++++++++++- .../SdkFlutterPlugin.swift | 4 +- .../didit_sdk_core/android/build.gradle.kts | 3 +- .../sdk/sdk_flutter/SdkFlutterPluginTest.kt | 236 +++++++++++++++++- .../didit_sdk_core/SdkFlutterPlugin.swift | 4 +- .../didit_sdk_nfc/android/build.gradle.kts | 3 +- .../sdk/sdk_flutter/SdkFlutterPluginTest.kt | 236 +++++++++++++++++- .../didit_sdk_nfc/SdkFlutterPlugin.swift | 4 +- 16 files changed, 1019 insertions(+), 74 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a75ca6..03c6a4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,12 @@ jobs: flutter pub get flutter build apk --debug + # The plugin's JVM unit tests (android/src/test) against the released + # native AAR, inside the Gradle project the build above generated. + - name: Plugin unit tests (Android) + working-directory: example/android + run: ./gradlew :didit_sdk:testDebugUnitTest + build-ios-example: name: Example app builds (iOS) runs-on: macos-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92b1719..73ed9f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,3 +12,7 @@ context with maintainers only through an approved private channel. ## Verification Run formatting, static analysis, and tests before opening a pull request. + +- Dart: `flutter analyze && flutter test` at the repository root. +- Android plugin: `flutter build apk --config-only` in `example`, then `./gradlew :didit_sdk:testDebugUnitTest` in `example/android`. +- iOS plugin (macOS): `pod install` in `example/ios`, then run Product > Test on the Runner scheme of `example/ios/Runner.xcworkspace`. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index a7dd3f8..bc31314 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -112,5 +112,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + testImplementation("io.mockk:mockk:1.14.11") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt b/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt index 07a2432..13e6f25 100644 --- a/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt +++ b/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt @@ -1,27 +1,239 @@ package me.didit.sdk.sdk_flutter +import android.app.Activity +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import me.didit.sdk.Configuration +import me.didit.sdk.DiditSdk +import me.didit.sdk.DiditSdkState +import me.didit.sdk.SessionData +import me.didit.sdk.VerificationError +import me.didit.sdk.VerificationResult +import me.didit.sdk.VerificationStatus +import me.didit.sdk.core.localization.SupportedLanguage +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals /* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * The native SDK runs every step of the flow, Bank and Location included, inside its own + * activity, and reports a single VerificationResult when the flow ends. These tests drive + * the plugin through the method channel against a stubbed DiditSdk: the call reaches the + * native SDK with the Dart configuration, the native UI opens once the session is ready, + * and every result shape reaches Dart as the map VerificationResult.fromMap reads. * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. + * Run them with `./gradlew :didit_sdk:testDebugUnitTest` from example/android. */ - +@OptIn(ExperimentalCoroutinesApi::class) internal class SdkFlutterPluginTest { + private val hostActivity = mockk() + private val result = mockk(relaxed = true) + private val sdkState = MutableStateFlow(DiditSdkState.Idle) + private val configuration = slot() + private val onResult = slot<(VerificationResult) -> Unit>() + private val delivered = mutableListOf() + private lateinit var plugin: SdkFlutterPlugin + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(DiditSdk) + every { DiditSdk.state } returns sdkState + every { DiditSdk.launchVerificationUI(any()) } just Runs + every { + DiditSdk.startVerification( + token = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { + DiditSdk.startVerification( + workflowId = any(), + vendorData = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { result.success(any()) } answers { delivered += firstArg() } + + plugin = SdkFlutterPlugin() + plugin.onAttachedToActivity(mockk { + every { activity } returns hostActivity + }) + } + + @AfterTest + fun tearDown() { + unmockkObject(DiditSdk) + Dispatchers.resetMain() + } + + private fun startVerification(config: Map = mapOf("languageCode" to "es")) { + plugin.onMethodCall( + MethodCall("startVerification", mapOf("token" to "session-token", "config" to config)), + result + ) + } + + // Starts a verification, lets the native SDK finish it with [nativeResult] and + // returns what the plugin sent back to Dart. + private fun deliver(nativeResult: VerificationResult): Any? { + startVerification() + onResult.captured(nativeResult) + return delivered.last() + } + @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = SdkFlutterPlugin() + fun startVerification_passesTokenAndConfigToNative_thenOpensItsUi() { + startVerification(mapOf("languageCode" to "es", "showCloseButton" to false)) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + verify { DiditSdk.startVerification(token = "session-token", configuration = any(), onResult = any()) } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + assertEquals(false, configuration.captured.showCloseButton) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + assertEquals>(emptyList(), delivered) + } + + @Test + fun startVerificationWithWorkflow_passesWorkflowAndVendorDataToNative_thenOpensItsUi() { + plugin.onMethodCall( + MethodCall( + "startVerificationWithWorkflow", + mapOf( + "workflowId" to "workflow-id", + "vendorData" to "user-1", + "config" to mapOf("languageCode" to "es") + ) + ), + result + ) + + verify { + DiditSdk.startVerification( + workflowId = "workflow-id", + vendorData = "user-1", + configuration = any(), + onResult = any() + ) + } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + } + + @Test + fun completedSession_returnsItsIdAndFinalStatus() { + for (status in VerificationStatus.entries) { + assertEquals( + mapOf("type" to "completed", "sessionId" to "session-1", "status" to status.rawValue), + deliver(VerificationResult.Completed(SessionData("session-1", status))) + ) + } + } + + @Test + fun cancelledSession_returnsTheSessionWhenOneExists() { + assertEquals( + mapOf("type" to "cancelled", "sessionId" to "session-1", "status" to "Pending"), + deliver(VerificationResult.Cancelled(SessionData("session-1", VerificationStatus.PENDING))) + ) + assertEquals(mapOf("type" to "cancelled"), deliver(VerificationResult.Cancelled(null))) + } + + @Test + fun failedSession_returnsTheErrorTypeAndMessage() { + val errorTypes = mapOf( + VerificationError.SessionExpired to "sessionExpired", + VerificationError.NetworkError to "networkError", + VerificationError.CameraAccessDenied to "cameraAccessDenied", + VerificationError.NotInitialized to "notInitialized", + VerificationError.RetryBlocked to "retryBlocked", + VerificationError.ApiError(503, "Service unavailable") to "apiError", + VerificationError.Unknown("Unsupported verification step") to "unknown" + ) + for ((error, errorType) in errorTypes) { + assertEquals( + mapOf( + "type" to "failed", + "errorType" to errorType, + "errorMessage" to error.message, + "sessionId" to "session-1", + "status" to "Pending" + ), + deliver( + VerificationResult.Failed(error, SessionData("session-1", VerificationStatus.PENDING)) + ) + ) + } + } + + @Test + fun nativeStartError_returnsTheNativeFailureWithoutOpeningTheUi() { + every { + DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) + } answers { + sdkState.value = DiditSdkState.Error("Session not found") + arg<(VerificationResult) -> Unit>(2)( + VerificationResult.Failed(VerificationError.Unknown("Session not found"), null) + ) + } + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf(mapOf("type" to "failed", "errorType" to "unknown", "errorMessage" to "Session not found")), + delivered + ) + } + + @Test + fun startVerification_withoutAnActivity_failsWithoutOpeningTheUi() { + plugin.onDetachedFromActivity() + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf( + mapOf( + "type" to "failed", + "errorType" to "unknown", + "errorMessage" to "No active Activity available to present verification UI." + ) + ), + delivered + ) + } + + @Test + fun startVerification_withoutAToken_isRejectedBeforeReachingNative() { + plugin.onMethodCall(MethodCall("startVerification", mapOf()), result) + + verify { result.error("INVALID_ARGUMENT", "Token is required", null) } + verify(exactly = 0) { DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) } + } + + @Test + fun unknownMethod_isNotImplemented() { + plugin.onMethodCall(MethodCall("getPlatformVersion", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify { result.notImplemented() } } } diff --git a/example/ios/Podfile b/example/ios/Podfile index 996f407..bf75dab 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -54,12 +54,12 @@ didit_sdk_ios_podspec = ENV.fetch( # to build the native SDK from source instead. It also adds the DiditSDK/Location module, # which the Location verification step needs and the released binaries do not include. # The tree's DiditSDK version must match the plugin's pin in ios/didit_sdk.podspec. -didit_sdk_ios_source = ENV['DIDIT_SDK_IOS_SOURCE'] +didit_sdk_ios_source = ENV.fetch('DIDIT_SDK_IOS_SOURCE', '').strip target 'Runner' do use_frameworks! - if didit_sdk_ios_source + unless didit_sdk_ios_source.empty? pod didit_sdk_ios_pod, :path => didit_sdk_ios_source pod 'DiditSDK/Location', :path => didit_sdk_ios_source else diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift index 8f41a95..2b00c2c 100644 --- a/example/ios/RunnerTests/RunnerTests.swift +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -1,27 +1,110 @@ +import DiditSDK import Flutter import UIKit import XCTest +@testable import didit_sdk -@testable import sdk_flutter - -// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// The native SDK runs every step of the flow, Bank and Location included, and reports a +// single VerificationResult when the flow ends. These tests cover the plugin's side of that +// hand-off: the Dart configuration it passes to the native SDK, the calls it rejects before +// presenting anything, and the map each result shape reaches Dart as. // -// See https://developer.apple.com/documentation/xctest for more information about using XCTest. +// Run them on a Mac: open Runner.xcworkspace and run Product > Test on the Runner scheme. class RunnerTests: XCTestCase { - func testGetPlatformVersion() { - let plugin = SdkFlutterPlugin() - - let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + private let plugin = SdkFlutterPlugin() - let resultExpectation = expectation(description: "result block must be called.") - plugin.handle(call) { result in - XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) - resultExpectation.fulfill() + private func reply(to method: String, arguments: Any?) -> Any? { + var reply: Any? + let replied = expectation(description: "\(method) replies") + plugin.handle(FlutterMethodCall(methodName: method, arguments: arguments)) { result in + reply = result + replied.fulfill() } waitForExpectations(timeout: 1) + return reply + } + + private func strings(_ map: [String: Any?]) -> [String: String] { + map.compactMapValues { $0 as? String } + } + + func testStartVerificationWithoutATokenIsRejected() { + let error = reply(to: "startVerification", arguments: [String: Any]()) as? FlutterError + + XCTAssertEqual(error?.code, "INVALID_ARGUMENT") + } + + func testStartVerificationWithWorkflowWithoutAWorkflowIdIsRejected() { + let error = reply(to: "startVerificationWithWorkflow", arguments: [String: Any]()) as? FlutterError + + XCTAssertEqual(error?.code, "INVALID_ARGUMENT") + } + + func testUnknownMethodIsNotImplemented() { + let result = reply(to: "getPlatformVersion", arguments: nil) as? NSObject + + XCTAssertTrue(result === FlutterMethodNotImplemented) } + func testConfigurationKeepsTheDartLanguageAndOptions() { + let configuration = plugin.parseConfiguration([ + "languageCode": "es", + "showCloseButton": false, + "defaultDocumentCamera": "front", + ]) + + XCTAssertEqual(configuration?.languageLocale, .spanish) + XCTAssertEqual(configuration?.showCloseButton, false) + XCTAssertEqual(configuration?.defaultDocumentCamera, .front) + } + + func testCompletedSessionReturnsItsIdAndFinalStatus() { + let statuses: [(VerificationStatus, String)] = [ + (.approved, "Approved"), + (.pending, "Pending"), + (.declined, "Declined"), + ] + for (status, rawStatus) in statuses { + let mapped = SdkFlutterPlugin.mapVerificationResult( + .completed(session: SessionData(sessionId: "session-1", status: status)) + ) + + XCTAssertEqual(strings(mapped), ["type": "completed", "sessionId": "session-1", "status": rawStatus]) + } + } + + func testCancelledSessionReturnsTheSessionWhenOneExists() { + let withSession = SdkFlutterPlugin.mapVerificationResult( + .cancelled(session: SessionData(sessionId: "session-1", status: .pending)) + ) + let withoutSession = SdkFlutterPlugin.mapVerificationResult(.cancelled(session: nil)) + + XCTAssertEqual(strings(withSession), ["type": "cancelled", "sessionId": "session-1", "status": "Pending"]) + XCTAssertEqual(strings(withoutSession), ["type": "cancelled"]) + } + + func testFailedSessionReturnsTheErrorTypeAndMessage() { + let errors: [(VerificationError, String)] = [ + (.sessionExpired, "sessionExpired"), + (.networkError, "networkError"), + (.cameraAccessDenied, "cameraAccessDenied"), + (.unknown("Unsupported verification step"), "unknown"), + ] + for (error, errorType) in errors { + let mapped = SdkFlutterPlugin.mapVerificationResult( + .failed(error: error, session: SessionData(sessionId: "session-1", status: .pending)) + ) + + XCTAssertEqual(strings(mapped), [ + "type": "failed", + "errorType": errorType, + "errorMessage": error.localizedDescription, + "sessionId": "session-1", + "status": "Pending", + ]) + } + } } diff --git a/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift b/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift index dca6a66..6f21bd3 100644 --- a/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift +++ b/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift @@ -185,7 +185,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { // MARK: - Configuration Parsing - private func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { + func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { guard let dict = dict else { return nil } var language: SupportedLanguage? @@ -230,7 +230,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { } } - private static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { + static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { switch result { case .completed(let session): return [ diff --git a/packages/didit_sdk_autodetection/android/build.gradle.kts b/packages/didit_sdk_autodetection/android/build.gradle.kts index 8a3e735..e964894 100644 --- a/packages/didit_sdk_autodetection/android/build.gradle.kts +++ b/packages/didit_sdk_autodetection/android/build.gradle.kts @@ -112,5 +112,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + testImplementation("io.mockk:mockk:1.14.11") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/packages/didit_sdk_autodetection/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt b/packages/didit_sdk_autodetection/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt index 07a2432..13e6f25 100644 --- a/packages/didit_sdk_autodetection/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt +++ b/packages/didit_sdk_autodetection/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt @@ -1,27 +1,239 @@ package me.didit.sdk.sdk_flutter +import android.app.Activity +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import me.didit.sdk.Configuration +import me.didit.sdk.DiditSdk +import me.didit.sdk.DiditSdkState +import me.didit.sdk.SessionData +import me.didit.sdk.VerificationError +import me.didit.sdk.VerificationResult +import me.didit.sdk.VerificationStatus +import me.didit.sdk.core.localization.SupportedLanguage +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals /* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * The native SDK runs every step of the flow, Bank and Location included, inside its own + * activity, and reports a single VerificationResult when the flow ends. These tests drive + * the plugin through the method channel against a stubbed DiditSdk: the call reaches the + * native SDK with the Dart configuration, the native UI opens once the session is ready, + * and every result shape reaches Dart as the map VerificationResult.fromMap reads. * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. + * Run them with `./gradlew :didit_sdk:testDebugUnitTest` from example/android. */ - +@OptIn(ExperimentalCoroutinesApi::class) internal class SdkFlutterPluginTest { + private val hostActivity = mockk() + private val result = mockk(relaxed = true) + private val sdkState = MutableStateFlow(DiditSdkState.Idle) + private val configuration = slot() + private val onResult = slot<(VerificationResult) -> Unit>() + private val delivered = mutableListOf() + private lateinit var plugin: SdkFlutterPlugin + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(DiditSdk) + every { DiditSdk.state } returns sdkState + every { DiditSdk.launchVerificationUI(any()) } just Runs + every { + DiditSdk.startVerification( + token = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { + DiditSdk.startVerification( + workflowId = any(), + vendorData = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { result.success(any()) } answers { delivered += firstArg() } + + plugin = SdkFlutterPlugin() + plugin.onAttachedToActivity(mockk { + every { activity } returns hostActivity + }) + } + + @AfterTest + fun tearDown() { + unmockkObject(DiditSdk) + Dispatchers.resetMain() + } + + private fun startVerification(config: Map = mapOf("languageCode" to "es")) { + plugin.onMethodCall( + MethodCall("startVerification", mapOf("token" to "session-token", "config" to config)), + result + ) + } + + // Starts a verification, lets the native SDK finish it with [nativeResult] and + // returns what the plugin sent back to Dart. + private fun deliver(nativeResult: VerificationResult): Any? { + startVerification() + onResult.captured(nativeResult) + return delivered.last() + } + @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = SdkFlutterPlugin() + fun startVerification_passesTokenAndConfigToNative_thenOpensItsUi() { + startVerification(mapOf("languageCode" to "es", "showCloseButton" to false)) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + verify { DiditSdk.startVerification(token = "session-token", configuration = any(), onResult = any()) } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + assertEquals(false, configuration.captured.showCloseButton) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + assertEquals>(emptyList(), delivered) + } + + @Test + fun startVerificationWithWorkflow_passesWorkflowAndVendorDataToNative_thenOpensItsUi() { + plugin.onMethodCall( + MethodCall( + "startVerificationWithWorkflow", + mapOf( + "workflowId" to "workflow-id", + "vendorData" to "user-1", + "config" to mapOf("languageCode" to "es") + ) + ), + result + ) + + verify { + DiditSdk.startVerification( + workflowId = "workflow-id", + vendorData = "user-1", + configuration = any(), + onResult = any() + ) + } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + } + + @Test + fun completedSession_returnsItsIdAndFinalStatus() { + for (status in VerificationStatus.entries) { + assertEquals( + mapOf("type" to "completed", "sessionId" to "session-1", "status" to status.rawValue), + deliver(VerificationResult.Completed(SessionData("session-1", status))) + ) + } + } + + @Test + fun cancelledSession_returnsTheSessionWhenOneExists() { + assertEquals( + mapOf("type" to "cancelled", "sessionId" to "session-1", "status" to "Pending"), + deliver(VerificationResult.Cancelled(SessionData("session-1", VerificationStatus.PENDING))) + ) + assertEquals(mapOf("type" to "cancelled"), deliver(VerificationResult.Cancelled(null))) + } + + @Test + fun failedSession_returnsTheErrorTypeAndMessage() { + val errorTypes = mapOf( + VerificationError.SessionExpired to "sessionExpired", + VerificationError.NetworkError to "networkError", + VerificationError.CameraAccessDenied to "cameraAccessDenied", + VerificationError.NotInitialized to "notInitialized", + VerificationError.RetryBlocked to "retryBlocked", + VerificationError.ApiError(503, "Service unavailable") to "apiError", + VerificationError.Unknown("Unsupported verification step") to "unknown" + ) + for ((error, errorType) in errorTypes) { + assertEquals( + mapOf( + "type" to "failed", + "errorType" to errorType, + "errorMessage" to error.message, + "sessionId" to "session-1", + "status" to "Pending" + ), + deliver( + VerificationResult.Failed(error, SessionData("session-1", VerificationStatus.PENDING)) + ) + ) + } + } + + @Test + fun nativeStartError_returnsTheNativeFailureWithoutOpeningTheUi() { + every { + DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) + } answers { + sdkState.value = DiditSdkState.Error("Session not found") + arg<(VerificationResult) -> Unit>(2)( + VerificationResult.Failed(VerificationError.Unknown("Session not found"), null) + ) + } + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf(mapOf("type" to "failed", "errorType" to "unknown", "errorMessage" to "Session not found")), + delivered + ) + } + + @Test + fun startVerification_withoutAnActivity_failsWithoutOpeningTheUi() { + plugin.onDetachedFromActivity() + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf( + mapOf( + "type" to "failed", + "errorType" to "unknown", + "errorMessage" to "No active Activity available to present verification UI." + ) + ), + delivered + ) + } + + @Test + fun startVerification_withoutAToken_isRejectedBeforeReachingNative() { + plugin.onMethodCall(MethodCall("startVerification", mapOf()), result) + + verify { result.error("INVALID_ARGUMENT", "Token is required", null) } + verify(exactly = 0) { DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) } + } + + @Test + fun unknownMethod_isNotImplemented() { + plugin.onMethodCall(MethodCall("getPlatformVersion", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify { result.notImplemented() } } } diff --git a/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift b/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift index dca6a66..6f21bd3 100644 --- a/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift @@ -185,7 +185,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { // MARK: - Configuration Parsing - private func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { + func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { guard let dict = dict else { return nil } var language: SupportedLanguage? @@ -230,7 +230,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { } } - private static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { + static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { switch result { case .completed(let session): return [ diff --git a/packages/didit_sdk_core/android/build.gradle.kts b/packages/didit_sdk_core/android/build.gradle.kts index b63e87b..084047a 100644 --- a/packages/didit_sdk_core/android/build.gradle.kts +++ b/packages/didit_sdk_core/android/build.gradle.kts @@ -112,5 +112,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + testImplementation("io.mockk:mockk:1.14.11") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/packages/didit_sdk_core/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt b/packages/didit_sdk_core/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt index 07a2432..13e6f25 100644 --- a/packages/didit_sdk_core/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt +++ b/packages/didit_sdk_core/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt @@ -1,27 +1,239 @@ package me.didit.sdk.sdk_flutter +import android.app.Activity +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import me.didit.sdk.Configuration +import me.didit.sdk.DiditSdk +import me.didit.sdk.DiditSdkState +import me.didit.sdk.SessionData +import me.didit.sdk.VerificationError +import me.didit.sdk.VerificationResult +import me.didit.sdk.VerificationStatus +import me.didit.sdk.core.localization.SupportedLanguage +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals /* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * The native SDK runs every step of the flow, Bank and Location included, inside its own + * activity, and reports a single VerificationResult when the flow ends. These tests drive + * the plugin through the method channel against a stubbed DiditSdk: the call reaches the + * native SDK with the Dart configuration, the native UI opens once the session is ready, + * and every result shape reaches Dart as the map VerificationResult.fromMap reads. * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. + * Run them with `./gradlew :didit_sdk:testDebugUnitTest` from example/android. */ - +@OptIn(ExperimentalCoroutinesApi::class) internal class SdkFlutterPluginTest { + private val hostActivity = mockk() + private val result = mockk(relaxed = true) + private val sdkState = MutableStateFlow(DiditSdkState.Idle) + private val configuration = slot() + private val onResult = slot<(VerificationResult) -> Unit>() + private val delivered = mutableListOf() + private lateinit var plugin: SdkFlutterPlugin + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(DiditSdk) + every { DiditSdk.state } returns sdkState + every { DiditSdk.launchVerificationUI(any()) } just Runs + every { + DiditSdk.startVerification( + token = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { + DiditSdk.startVerification( + workflowId = any(), + vendorData = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { result.success(any()) } answers { delivered += firstArg() } + + plugin = SdkFlutterPlugin() + plugin.onAttachedToActivity(mockk { + every { activity } returns hostActivity + }) + } + + @AfterTest + fun tearDown() { + unmockkObject(DiditSdk) + Dispatchers.resetMain() + } + + private fun startVerification(config: Map = mapOf("languageCode" to "es")) { + plugin.onMethodCall( + MethodCall("startVerification", mapOf("token" to "session-token", "config" to config)), + result + ) + } + + // Starts a verification, lets the native SDK finish it with [nativeResult] and + // returns what the plugin sent back to Dart. + private fun deliver(nativeResult: VerificationResult): Any? { + startVerification() + onResult.captured(nativeResult) + return delivered.last() + } + @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = SdkFlutterPlugin() + fun startVerification_passesTokenAndConfigToNative_thenOpensItsUi() { + startVerification(mapOf("languageCode" to "es", "showCloseButton" to false)) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + verify { DiditSdk.startVerification(token = "session-token", configuration = any(), onResult = any()) } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + assertEquals(false, configuration.captured.showCloseButton) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + assertEquals>(emptyList(), delivered) + } + + @Test + fun startVerificationWithWorkflow_passesWorkflowAndVendorDataToNative_thenOpensItsUi() { + plugin.onMethodCall( + MethodCall( + "startVerificationWithWorkflow", + mapOf( + "workflowId" to "workflow-id", + "vendorData" to "user-1", + "config" to mapOf("languageCode" to "es") + ) + ), + result + ) + + verify { + DiditSdk.startVerification( + workflowId = "workflow-id", + vendorData = "user-1", + configuration = any(), + onResult = any() + ) + } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + } + + @Test + fun completedSession_returnsItsIdAndFinalStatus() { + for (status in VerificationStatus.entries) { + assertEquals( + mapOf("type" to "completed", "sessionId" to "session-1", "status" to status.rawValue), + deliver(VerificationResult.Completed(SessionData("session-1", status))) + ) + } + } + + @Test + fun cancelledSession_returnsTheSessionWhenOneExists() { + assertEquals( + mapOf("type" to "cancelled", "sessionId" to "session-1", "status" to "Pending"), + deliver(VerificationResult.Cancelled(SessionData("session-1", VerificationStatus.PENDING))) + ) + assertEquals(mapOf("type" to "cancelled"), deliver(VerificationResult.Cancelled(null))) + } + + @Test + fun failedSession_returnsTheErrorTypeAndMessage() { + val errorTypes = mapOf( + VerificationError.SessionExpired to "sessionExpired", + VerificationError.NetworkError to "networkError", + VerificationError.CameraAccessDenied to "cameraAccessDenied", + VerificationError.NotInitialized to "notInitialized", + VerificationError.RetryBlocked to "retryBlocked", + VerificationError.ApiError(503, "Service unavailable") to "apiError", + VerificationError.Unknown("Unsupported verification step") to "unknown" + ) + for ((error, errorType) in errorTypes) { + assertEquals( + mapOf( + "type" to "failed", + "errorType" to errorType, + "errorMessage" to error.message, + "sessionId" to "session-1", + "status" to "Pending" + ), + deliver( + VerificationResult.Failed(error, SessionData("session-1", VerificationStatus.PENDING)) + ) + ) + } + } + + @Test + fun nativeStartError_returnsTheNativeFailureWithoutOpeningTheUi() { + every { + DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) + } answers { + sdkState.value = DiditSdkState.Error("Session not found") + arg<(VerificationResult) -> Unit>(2)( + VerificationResult.Failed(VerificationError.Unknown("Session not found"), null) + ) + } + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf(mapOf("type" to "failed", "errorType" to "unknown", "errorMessage" to "Session not found")), + delivered + ) + } + + @Test + fun startVerification_withoutAnActivity_failsWithoutOpeningTheUi() { + plugin.onDetachedFromActivity() + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf( + mapOf( + "type" to "failed", + "errorType" to "unknown", + "errorMessage" to "No active Activity available to present verification UI." + ) + ), + delivered + ) + } + + @Test + fun startVerification_withoutAToken_isRejectedBeforeReachingNative() { + plugin.onMethodCall(MethodCall("startVerification", mapOf()), result) + + verify { result.error("INVALID_ARGUMENT", "Token is required", null) } + verify(exactly = 0) { DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) } + } + + @Test + fun unknownMethod_isNotImplemented() { + plugin.onMethodCall(MethodCall("getPlatformVersion", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify { result.notImplemented() } } } diff --git a/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift b/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift index dca6a66..6f21bd3 100644 --- a/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift @@ -185,7 +185,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { // MARK: - Configuration Parsing - private func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { + func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { guard let dict = dict else { return nil } var language: SupportedLanguage? @@ -230,7 +230,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { } } - private static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { + static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { switch result { case .completed(let session): return [ diff --git a/packages/didit_sdk_nfc/android/build.gradle.kts b/packages/didit_sdk_nfc/android/build.gradle.kts index 84b64e9..aeddf10 100644 --- a/packages/didit_sdk_nfc/android/build.gradle.kts +++ b/packages/didit_sdk_nfc/android/build.gradle.kts @@ -112,5 +112,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + testImplementation("io.mockk:mockk:1.14.11") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/packages/didit_sdk_nfc/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt b/packages/didit_sdk_nfc/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt index 07a2432..13e6f25 100644 --- a/packages/didit_sdk_nfc/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt +++ b/packages/didit_sdk_nfc/android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt @@ -1,27 +1,239 @@ package me.didit.sdk.sdk_flutter +import android.app.Activity +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import me.didit.sdk.Configuration +import me.didit.sdk.DiditSdk +import me.didit.sdk.DiditSdkState +import me.didit.sdk.SessionData +import me.didit.sdk.VerificationError +import me.didit.sdk.VerificationResult +import me.didit.sdk.VerificationStatus +import me.didit.sdk.core.localization.SupportedLanguage +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals /* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * The native SDK runs every step of the flow, Bank and Location included, inside its own + * activity, and reports a single VerificationResult when the flow ends. These tests drive + * the plugin through the method channel against a stubbed DiditSdk: the call reaches the + * native SDK with the Dart configuration, the native UI opens once the session is ready, + * and every result shape reaches Dart as the map VerificationResult.fromMap reads. * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. + * Run them with `./gradlew :didit_sdk:testDebugUnitTest` from example/android. */ - +@OptIn(ExperimentalCoroutinesApi::class) internal class SdkFlutterPluginTest { + private val hostActivity = mockk() + private val result = mockk(relaxed = true) + private val sdkState = MutableStateFlow(DiditSdkState.Idle) + private val configuration = slot() + private val onResult = slot<(VerificationResult) -> Unit>() + private val delivered = mutableListOf() + private lateinit var plugin: SdkFlutterPlugin + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(DiditSdk) + every { DiditSdk.state } returns sdkState + every { DiditSdk.launchVerificationUI(any()) } just Runs + every { + DiditSdk.startVerification( + token = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { + DiditSdk.startVerification( + workflowId = any(), + vendorData = any(), + configuration = capture(configuration), + onResult = capture(onResult) + ) + } answers { sdkState.value = DiditSdkState.Ready } + every { result.success(any()) } answers { delivered += firstArg() } + + plugin = SdkFlutterPlugin() + plugin.onAttachedToActivity(mockk { + every { activity } returns hostActivity + }) + } + + @AfterTest + fun tearDown() { + unmockkObject(DiditSdk) + Dispatchers.resetMain() + } + + private fun startVerification(config: Map = mapOf("languageCode" to "es")) { + plugin.onMethodCall( + MethodCall("startVerification", mapOf("token" to "session-token", "config" to config)), + result + ) + } + + // Starts a verification, lets the native SDK finish it with [nativeResult] and + // returns what the plugin sent back to Dart. + private fun deliver(nativeResult: VerificationResult): Any? { + startVerification() + onResult.captured(nativeResult) + return delivered.last() + } + @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = SdkFlutterPlugin() + fun startVerification_passesTokenAndConfigToNative_thenOpensItsUi() { + startVerification(mapOf("languageCode" to "es", "showCloseButton" to false)) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + verify { DiditSdk.startVerification(token = "session-token", configuration = any(), onResult = any()) } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + assertEquals(false, configuration.captured.showCloseButton) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + assertEquals>(emptyList(), delivered) + } + + @Test + fun startVerificationWithWorkflow_passesWorkflowAndVendorDataToNative_thenOpensItsUi() { + plugin.onMethodCall( + MethodCall( + "startVerificationWithWorkflow", + mapOf( + "workflowId" to "workflow-id", + "vendorData" to "user-1", + "config" to mapOf("languageCode" to "es") + ) + ), + result + ) + + verify { + DiditSdk.startVerification( + workflowId = "workflow-id", + vendorData = "user-1", + configuration = any(), + onResult = any() + ) + } + assertEquals(SupportedLanguage.SPANISH, configuration.captured.languageLocale) + verify(exactly = 1) { DiditSdk.launchVerificationUI(hostActivity) } + } + + @Test + fun completedSession_returnsItsIdAndFinalStatus() { + for (status in VerificationStatus.entries) { + assertEquals( + mapOf("type" to "completed", "sessionId" to "session-1", "status" to status.rawValue), + deliver(VerificationResult.Completed(SessionData("session-1", status))) + ) + } + } + + @Test + fun cancelledSession_returnsTheSessionWhenOneExists() { + assertEquals( + mapOf("type" to "cancelled", "sessionId" to "session-1", "status" to "Pending"), + deliver(VerificationResult.Cancelled(SessionData("session-1", VerificationStatus.PENDING))) + ) + assertEquals(mapOf("type" to "cancelled"), deliver(VerificationResult.Cancelled(null))) + } + + @Test + fun failedSession_returnsTheErrorTypeAndMessage() { + val errorTypes = mapOf( + VerificationError.SessionExpired to "sessionExpired", + VerificationError.NetworkError to "networkError", + VerificationError.CameraAccessDenied to "cameraAccessDenied", + VerificationError.NotInitialized to "notInitialized", + VerificationError.RetryBlocked to "retryBlocked", + VerificationError.ApiError(503, "Service unavailable") to "apiError", + VerificationError.Unknown("Unsupported verification step") to "unknown" + ) + for ((error, errorType) in errorTypes) { + assertEquals( + mapOf( + "type" to "failed", + "errorType" to errorType, + "errorMessage" to error.message, + "sessionId" to "session-1", + "status" to "Pending" + ), + deliver( + VerificationResult.Failed(error, SessionData("session-1", VerificationStatus.PENDING)) + ) + ) + } + } + + @Test + fun nativeStartError_returnsTheNativeFailureWithoutOpeningTheUi() { + every { + DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) + } answers { + sdkState.value = DiditSdkState.Error("Session not found") + arg<(VerificationResult) -> Unit>(2)( + VerificationResult.Failed(VerificationError.Unknown("Session not found"), null) + ) + } + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf(mapOf("type" to "failed", "errorType" to "unknown", "errorMessage" to "Session not found")), + delivered + ) + } + + @Test + fun startVerification_withoutAnActivity_failsWithoutOpeningTheUi() { + plugin.onDetachedFromActivity() + + startVerification() + + verify(exactly = 0) { DiditSdk.launchVerificationUI(any()) } + assertEquals>( + listOf( + mapOf( + "type" to "failed", + "errorType" to "unknown", + "errorMessage" to "No active Activity available to present verification UI." + ) + ), + delivered + ) + } + + @Test + fun startVerification_withoutAToken_isRejectedBeforeReachingNative() { + plugin.onMethodCall(MethodCall("startVerification", mapOf()), result) + + verify { result.error("INVALID_ARGUMENT", "Token is required", null) } + verify(exactly = 0) { DiditSdk.startVerification(token = any(), configuration = any(), onResult = any()) } + } + + @Test + fun unknownMethod_isNotImplemented() { + plugin.onMethodCall(MethodCall("getPlatformVersion", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify { result.notImplemented() } } } diff --git a/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift b/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift index dca6a66..6f21bd3 100644 --- a/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift @@ -185,7 +185,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { // MARK: - Configuration Parsing - private func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { + func parseConfiguration(_ dict: [String: Any]?) -> DiditSdk.Configuration? { guard let dict = dict else { return nil } var language: SupportedLanguage? @@ -230,7 +230,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { } } - private static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { + static func mapVerificationResult(_ result: VerificationResult) -> [String: Any?] { switch result { case .completed(let session): return [ From 8486add1ce68a1c199083426b8449494adf5fe7d Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Wed, 7 Oct 2026 22:30:18 +0000 Subject: [PATCH 3/5] DID-5768: [H11] Flutter SDK: Bank and Location steps pass-through (continuation of DID-5660) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- .github/workflows/ci.yml | 8 +++++ CONTRIBUTING.md | 2 +- README.md | 1 + example/ios/Runner.xcodeproj/project.pbxproj | 3 ++ example/ios/RunnerTests/RunnerTests.swift | 32 ++++++++++++++++++- .../Sources/didit_sdk/SdkFlutterPlugin.swift | 19 +++++++---- .../SdkFlutterPlugin.swift | 19 +++++++---- .../didit_sdk_core/SdkFlutterPlugin.swift | 19 +++++++---- .../didit_sdk_nfc/SdkFlutterPlugin.swift | 19 +++++++---- test/sdk_flutter_method_channel_test.dart | 32 +++++++++++++++++++ 10 files changed, 124 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03c6a4c..7054166 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,3 +122,11 @@ jobs: flutter config --no-enable-swift-package-manager flutter pub get flutter build ios --debug --no-codesign + + # The plugin's XCTests (example/ios/RunnerTests) on a simulator, against + # the same released native binaries. + - name: Plugin unit tests (iOS) + working-directory: example/ios + run: | + udid=$(xcrun simctl list devices available --json | python3 -c 'import json, sys; print(next(d["udid"] for runtime, devices in json.load(sys.stdin)["devices"].items() if "iOS" in runtime for d in devices if d["name"].startswith("iPhone")))') + xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration Debug -destination "id=$udid" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73ed9f3..ad7f093 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,4 +15,4 @@ Run formatting, static analysis, and tests before opening a pull request. - Dart: `flutter analyze && flutter test` at the repository root. - Android plugin: `flutter build apk --config-only` in `example`, then `./gradlew :didit_sdk:testDebugUnitTest` in `example/android`. -- iOS plugin (macOS): `pod install` in `example/ios`, then run Product > Test on the Runner scheme of `example/ios/Runner.xcworkspace`. +- iOS plugin (macOS): `flutter build ios --debug --no-codesign` in `example`, then run Product > Test on the Runner scheme of `example/ios/Runner.xcworkspace` (or `xcodebuild test -workspace Runner.xcworkspace -scheme Runner -destination 'platform=iOS Simulator,name='` in `example/ios`). diff --git a/README.md b/README.md index 84ee068..2acc2e6 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,7 @@ Both `startVerification` and `startVerificationWithWorkflow` return a `Future Test on the Runner scheme. +// CI runs them in the iOS example job; CONTRIBUTING.md shows how to run them on a Mac. class RunnerTests: XCTestCase { @@ -89,6 +89,7 @@ class RunnerTests: XCTestCase { func testFailedSessionReturnsTheErrorTypeAndMessage() { let errors: [(VerificationError, String)] = [ (.sessionExpired, "sessionExpired"), + (.retryBlocked, "retryBlocked"), (.networkError, "networkError"), (.cameraAccessDenied, "cameraAccessDenied"), (.unknown("Unsupported verification step"), "unknown"), @@ -107,4 +108,33 @@ class RunnerTests: XCTestCase { ]) } } + + // The native SDK fails with retryBlocked, and the declined session, when the person has no + // retries left. The result takes the path of the `.diditVerification` callback and is encoded + // with the codec the Dart side of the channel decodes. + func testNativeRetryBlockedCallbackReachesDartAsRetryBlocked() { + let generation = plugin.beginPresentation() + var reply: Any? + let replied = expectation(description: "the native result answers the Dart call") + + plugin.deliver( + .failed(error: .retryBlocked, session: SessionData(sessionId: "session-1", status: .declined)), + generation: generation + ) { result in + reply = result + replied.fulfill() + } + waitForExpectations(timeout: 2) + + let codec = FlutterStandardMethodCodec.sharedInstance() + let received = codec.decodeEnvelope(codec.encodeSuccessEnvelope(reply)) as? [String: Any] + + XCTAssertEqual(received?.compactMapValues { $0 as? String }, [ + "type": "failed", + "errorType": "retryBlocked", + "errorMessage": VerificationError.retryBlocked.localizedDescription, + "sessionId": "session-1", + "status": "Declined", + ]) + } } diff --git a/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift b/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift index 6f21bd3..fb83464 100644 --- a/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift +++ b/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift @@ -96,12 +96,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { let bridgeView = DiditBridgeView( onResult: { [weak self] verificationResult in - guard let self = self, self.claimResultDelivery(for: generation) else { return } - self.tearDownThenResolve( - generation: generation, - mapped: Self.mapVerificationResult(verificationResult), - result: result - ) + self?.deliver(verificationResult, generation: generation, result: result) } ) @@ -119,12 +114,21 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { hostingController = nil } - private func beginPresentation() -> Int { + func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } + func deliver(_ verificationResult: VerificationResult, generation: Int, result: @escaping FlutterResult) { + guard claimResultDelivery(for: generation) else { return } + tearDownThenResolve( + generation: generation, + mapped: Self.mapVerificationResult(verificationResult), + result: result + ) + } + private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true @@ -271,6 +275,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { private static func mapErrorType(_ error: VerificationError) -> String { switch error { case .sessionExpired: return "sessionExpired" + case .retryBlocked: return "retryBlocked" case .networkError: return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" case .unknown: return "unknown" diff --git a/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift b/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift index 6f21bd3..fb83464 100644 --- a/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift @@ -96,12 +96,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { let bridgeView = DiditBridgeView( onResult: { [weak self] verificationResult in - guard let self = self, self.claimResultDelivery(for: generation) else { return } - self.tearDownThenResolve( - generation: generation, - mapped: Self.mapVerificationResult(verificationResult), - result: result - ) + self?.deliver(verificationResult, generation: generation, result: result) } ) @@ -119,12 +114,21 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { hostingController = nil } - private func beginPresentation() -> Int { + func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } + func deliver(_ verificationResult: VerificationResult, generation: Int, result: @escaping FlutterResult) { + guard claimResultDelivery(for: generation) else { return } + tearDownThenResolve( + generation: generation, + mapped: Self.mapVerificationResult(verificationResult), + result: result + ) + } + private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true @@ -271,6 +275,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { private static func mapErrorType(_ error: VerificationError) -> String { switch error { case .sessionExpired: return "sessionExpired" + case .retryBlocked: return "retryBlocked" case .networkError: return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" case .unknown: return "unknown" diff --git a/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift b/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift index 6f21bd3..fb83464 100644 --- a/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift @@ -96,12 +96,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { let bridgeView = DiditBridgeView( onResult: { [weak self] verificationResult in - guard let self = self, self.claimResultDelivery(for: generation) else { return } - self.tearDownThenResolve( - generation: generation, - mapped: Self.mapVerificationResult(verificationResult), - result: result - ) + self?.deliver(verificationResult, generation: generation, result: result) } ) @@ -119,12 +114,21 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { hostingController = nil } - private func beginPresentation() -> Int { + func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } + func deliver(_ verificationResult: VerificationResult, generation: Int, result: @escaping FlutterResult) { + guard claimResultDelivery(for: generation) else { return } + tearDownThenResolve( + generation: generation, + mapped: Self.mapVerificationResult(verificationResult), + result: result + ) + } + private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true @@ -271,6 +275,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { private static func mapErrorType(_ error: VerificationError) -> String { switch error { case .sessionExpired: return "sessionExpired" + case .retryBlocked: return "retryBlocked" case .networkError: return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" case .unknown: return "unknown" diff --git a/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift b/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift index 6f21bd3..fb83464 100644 --- a/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift @@ -96,12 +96,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { let bridgeView = DiditBridgeView( onResult: { [weak self] verificationResult in - guard let self = self, self.claimResultDelivery(for: generation) else { return } - self.tearDownThenResolve( - generation: generation, - mapped: Self.mapVerificationResult(verificationResult), - result: result - ) + self?.deliver(verificationResult, generation: generation, result: result) } ) @@ -119,12 +114,21 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { hostingController = nil } - private func beginPresentation() -> Int { + func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } + func deliver(_ verificationResult: VerificationResult, generation: Int, result: @escaping FlutterResult) { + guard claimResultDelivery(for: generation) else { return } + tearDownThenResolve( + generation: generation, + mapped: Self.mapVerificationResult(verificationResult), + result: result + ) + } + private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true @@ -271,6 +275,7 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { private static func mapErrorType(_ error: VerificationError) -> String { switch error { case .sessionExpired: return "sessionExpired" + case .retryBlocked: return "retryBlocked" case .networkError: return "networkError" case .cameraAccessDenied: return "cameraAccessDenied" case .unknown: return "unknown" diff --git a/test/sdk_flutter_method_channel_test.dart b/test/sdk_flutter_method_channel_test.dart index a9f37bf..8860c5e 100644 --- a/test/sdk_flutter_method_channel_test.dart +++ b/test/sdk_flutter_method_channel_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:didit_sdk/sdk_flutter.dart'; import 'package:didit_sdk/sdk_flutter_method_channel.dart'; void main() { @@ -57,6 +58,37 @@ void main() { expect(result['sessionId'], 'test-workflow-session-id'); }); + test( + 'a retryBlocked failure from the platform reaches Dart as retryBlocked', + () async { + const message = + 'You have reached the maximum number of verification attempts.'; + // The map the iOS plugin sends when the native SDK fails with retryBlocked. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + expect(methodCall.method, 'startVerificationWithWorkflow'); + return { + 'type': 'failed', + 'errorType': 'retryBlocked', + 'errorMessage': message, + 'sessionId': 'session-1', + 'status': 'Declined', + }; + }); + + final result = await DiditSdk.startVerificationWithWorkflow( + 'test-workflow', + ); + + expect(result, isA()); + final failed = result as VerificationFailed; + expect(failed.error.type, VerificationErrorType.retryBlocked); + expect(failed.error.message, message); + expect(failed.session?.sessionId, 'session-1'); + expect(failed.session?.status, VerificationStatus.declined); + }, + ); + test('submitTransaction calls platform channel with payload', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { From 2f4c4060cd294212b1959c113aeb0e4c922522ef Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Wed, 7 Oct 2026 23:10:55 +0000 Subject: [PATCH 4/5] DID-5768: [H11] Flutter SDK: Bank and Location steps pass-through (continuation of DID-5660) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- .github/workflows/ci.yml | 6 ++ CONTRIBUTING.md | 1 + .../integration_test/retry_blocked_test.dart | 31 ++++++ example/ios/RunnerTests/RunnerTests.swift | 33 +------ .../Sources/didit_sdk/SdkFlutterPlugin.swift | 18 ++-- .../SdkFlutterPlugin.swift | 18 ++-- .../didit_sdk_core/SdkFlutterPlugin.swift | 18 ++-- .../didit_sdk_nfc/SdkFlutterPlugin.swift | 18 ++-- tool/ios_bridge_test.sh | 98 +++++++++++++++++++ tool/stand_in_api.py | 94 ++++++++++++++++++ 10 files changed, 261 insertions(+), 74 deletions(-) create mode 100644 example/integration_test/retry_blocked_test.dart create mode 100755 tool/ios_bridge_test.sh create mode 100755 tool/stand_in_api.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7054166..6e4481b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,3 +130,9 @@ jobs: run: | udid=$(xcrun simctl list devices available --json | python3 -c 'import json, sys; print(next(d["udid"] for runtime, devices in json.load(sys.stdin)["devices"].items() if "iOS" in runtime for d in devices if d["name"].startswith("iPhone")))') xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration Debug -destination "id=$udid" + + # Dart to the released native SDK and back on a simulator: the native SDK + # ends the flow through its own callback with retryBlocked, against a + # stand-in for the verification API, and Dart checks the result. + - name: Bridge test (iOS) + run: bash tool/ios_bridge_test.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad7f093..7f07994 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,3 +16,4 @@ Run formatting, static analysis, and tests before opening a pull request. - Dart: `flutter analyze && flutter test` at the repository root. - Android plugin: `flutter build apk --config-only` in `example`, then `./gradlew :didit_sdk:testDebugUnitTest` in `example/android`. - iOS plugin (macOS): `flutter build ios --debug --no-codesign` in `example`, then run Product > Test on the Runner scheme of `example/ios/Runner.xcworkspace` (or `xcodebuild test -workspace Runner.xcworkspace -scheme Runner -destination 'platform=iOS Simulator,name='` in `example/ios`). +- iOS bridge (macOS, sudo): `tool/ios_bridge_test.sh [simulator udid]` runs a native `retryBlocked` result from Dart to the native SDK and back on a simulator, against a local stand-in for the verification API. diff --git a/example/integration_test/retry_blocked_test.dart b/example/integration_test/retry_blocked_test.dart new file mode 100644 index 0000000..918cb09 --- /dev/null +++ b/example/integration_test/retry_blocked_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:didit_sdk/sdk_flutter.dart'; + +// tool/ios_bridge_test.sh runs this on an iOS simulator against tool/stand_in_api.py, +// which answers the native SDK's session request the way the verification API answers +// a person with no retries left. Without the stand-in the request would reach the real +// API, so the test is skipped. +const _standInApi = bool.fromEnvironment('DIDIT_STAND_IN_API'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'the native retryBlocked callback reaches Dart as retryBlocked', + (WidgetTester tester) async { + final result = await DiditSdk.startVerificationWithWorkflow( + 'retry-blocked-workflow', + ); + + expect(result, isA()); + final failed = result as VerificationFailed; + expect(failed.error.type, VerificationErrorType.retryBlocked); + expect(failed.session?.sessionId, 'retry-blocked-session'); + expect(failed.session?.status, VerificationStatus.declined); + }, + skip: !_standInApi, + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift index 7bda938..48f2f00 100644 --- a/example/ios/RunnerTests/RunnerTests.swift +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -8,7 +8,9 @@ import XCTest // The native SDK runs every step of the flow, Bank and Location included, and reports a // single VerificationResult when the flow ends. These tests cover the plugin's side of that // hand-off: the Dart configuration it passes to the native SDK, the calls it rejects before -// presenting anything, and the map each result shape reaches Dart as. +// presenting anything, and the map each result shape reaches Dart as. The whole round trip, +// from Dart through the native SDK's own callback and back, is checked by +// example/integration_test/retry_blocked_test.dart (tool/ios_bridge_test.sh). // // CI runs them in the iOS example job; CONTRIBUTING.md shows how to run them on a Mac. @@ -108,33 +110,4 @@ class RunnerTests: XCTestCase { ]) } } - - // The native SDK fails with retryBlocked, and the declined session, when the person has no - // retries left. The result takes the path of the `.diditVerification` callback and is encoded - // with the codec the Dart side of the channel decodes. - func testNativeRetryBlockedCallbackReachesDartAsRetryBlocked() { - let generation = plugin.beginPresentation() - var reply: Any? - let replied = expectation(description: "the native result answers the Dart call") - - plugin.deliver( - .failed(error: .retryBlocked, session: SessionData(sessionId: "session-1", status: .declined)), - generation: generation - ) { result in - reply = result - replied.fulfill() - } - waitForExpectations(timeout: 2) - - let codec = FlutterStandardMethodCodec.sharedInstance() - let received = codec.decodeEnvelope(codec.encodeSuccessEnvelope(reply)) as? [String: Any] - - XCTAssertEqual(received?.compactMapValues { $0 as? String }, [ - "type": "failed", - "errorType": "retryBlocked", - "errorMessage": VerificationError.retryBlocked.localizedDescription, - "sessionId": "session-1", - "status": "Declined", - ]) - } } diff --git a/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift b/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift index fb83464..9037d98 100644 --- a/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift +++ b/ios/didit_sdk/Sources/didit_sdk/SdkFlutterPlugin.swift @@ -96,7 +96,12 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { let bridgeView = DiditBridgeView( onResult: { [weak self] verificationResult in - self?.deliver(verificationResult, generation: generation, result: result) + guard let self = self, self.claimResultDelivery(for: generation) else { return } + self.tearDownThenResolve( + generation: generation, + mapped: Self.mapVerificationResult(verificationResult), + result: result + ) } ) @@ -114,21 +119,12 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { hostingController = nil } - func beginPresentation() -> Int { + private func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } - func deliver(_ verificationResult: VerificationResult, generation: Int, result: @escaping FlutterResult) { - guard claimResultDelivery(for: generation) else { return } - tearDownThenResolve( - generation: generation, - mapped: Self.mapVerificationResult(verificationResult), - result: result - ) - } - private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true diff --git a/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift b/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift index fb83464..9037d98 100644 --- a/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_autodetection/ios/didit_sdk_autodetection/Sources/didit_sdk_autodetection/SdkFlutterPlugin.swift @@ -96,7 +96,12 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { let bridgeView = DiditBridgeView( onResult: { [weak self] verificationResult in - self?.deliver(verificationResult, generation: generation, result: result) + guard let self = self, self.claimResultDelivery(for: generation) else { return } + self.tearDownThenResolve( + generation: generation, + mapped: Self.mapVerificationResult(verificationResult), + result: result + ) } ) @@ -114,21 +119,12 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { hostingController = nil } - func beginPresentation() -> Int { + private func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } - func deliver(_ verificationResult: VerificationResult, generation: Int, result: @escaping FlutterResult) { - guard claimResultDelivery(for: generation) else { return } - tearDownThenResolve( - generation: generation, - mapped: Self.mapVerificationResult(verificationResult), - result: result - ) - } - private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true diff --git a/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift b/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift index fb83464..9037d98 100644 --- a/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_core/ios/didit_sdk_core/Sources/didit_sdk_core/SdkFlutterPlugin.swift @@ -96,7 +96,12 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { let bridgeView = DiditBridgeView( onResult: { [weak self] verificationResult in - self?.deliver(verificationResult, generation: generation, result: result) + guard let self = self, self.claimResultDelivery(for: generation) else { return } + self.tearDownThenResolve( + generation: generation, + mapped: Self.mapVerificationResult(verificationResult), + result: result + ) } ) @@ -114,21 +119,12 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { hostingController = nil } - func beginPresentation() -> Int { + private func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } - func deliver(_ verificationResult: VerificationResult, generation: Int, result: @escaping FlutterResult) { - guard claimResultDelivery(for: generation) else { return } - tearDownThenResolve( - generation: generation, - mapped: Self.mapVerificationResult(verificationResult), - result: result - ) - } - private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true diff --git a/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift b/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift index fb83464..9037d98 100644 --- a/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift +++ b/packages/didit_sdk_nfc/ios/didit_sdk_nfc/Sources/didit_sdk_nfc/SdkFlutterPlugin.swift @@ -96,7 +96,12 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { let bridgeView = DiditBridgeView( onResult: { [weak self] verificationResult in - self?.deliver(verificationResult, generation: generation, result: result) + guard let self = self, self.claimResultDelivery(for: generation) else { return } + self.tearDownThenResolve( + generation: generation, + mapped: Self.mapVerificationResult(verificationResult), + result: result + ) } ) @@ -114,21 +119,12 @@ public class SdkFlutterPlugin: NSObject, FlutterPlugin { hostingController = nil } - func beginPresentation() -> Int { + private func beginPresentation() -> Int { presentationGeneration += 1 hasDeliveredResult = false return presentationGeneration } - func deliver(_ verificationResult: VerificationResult, generation: Int, result: @escaping FlutterResult) { - guard claimResultDelivery(for: generation) else { return } - tearDownThenResolve( - generation: generation, - mapped: Self.mapVerificationResult(verificationResult), - result: result - ) - } - private func claimResultDelivery(for generation: Int) -> Bool { guard !hasDeliveredResult, presentationGeneration == generation else { return false } hasDeliveredResult = true diff --git a/tool/ios_bridge_test.sh b/tool/ios_bridge_test.sh new file mode 100755 index 0000000..9d6111e --- /dev/null +++ b/tool/ios_bridge_test.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Checks the iOS bridge end to end on a simulator. Dart calls +# DiditSdk.startVerificationWithWorkflow (example/integration_test/ +# retry_blocked_test.dart), the plugin starts the pinned native SDK, and the +# native SDK sends its session request to tool/stand_in_api.py, which answers +# the way the verification API answers a person with no retries left. The +# native SDK then ends the flow through its own callback, and the test checks +# the result Dart receives. +# +# macOS with Xcode and Flutter, Swift Package Manager disabled +# (`flutter config --no-enable-swift-package-manager`, as in the iOS CI job). +# It uses sudo to point the API host at this machine in /etc/hosts and restores +# the file on exit. The simulator keeps trusting the throwaway certificate +# authority, whose private key is deleted on exit. +# +# Usage: tool/ios_bridge_test.sh [simulator udid] (default: the first available iPhone) +set -euo pipefail + +cd "$(dirname "$0")/.." + +api_host=verification.didit.me +udid=${1:-$(xcrun simctl list devices available --json | python3 -c 'import json, sys; print(next(d["udid"] for runtime, devices in json.load(sys.stdin)["devices"].items() if "iOS" in runtime for d in devices if d["name"].startswith("iPhone")))')} +work=$(mktemp -d) + +flush_dns() { + sudo dscacheutil -flushcache + sudo killall -HUP mDNSResponder || true +} + +cleanup() { + if [[ -n "${server_pid:-}" ]]; then kill "$server_pid" 2>/dev/null || true; fi + if [[ -f "$work/hosts" ]]; then + sudo cp "$work/hosts" /etc/hosts + flush_dns + fi + if [[ -f "$work/server.log" ]]; then + echo "Requests the stand-in API received:" + cat "$work/server.log" + fi + rm -rf "$work" +} +trap cleanup EXIT + +# A certificate authority the simulator trusts, and a certificate for the API +# host that meets iOS's rules for TLS server certificates. +cat > "$work/ca.cnf" < "$work/server.ext" </dev/null +openssl req -new -newkey rsa:2048 -nodes -sha256 -subj "/CN=$api_host" \ + -keyout "$work/server.key" -out "$work/server.csr" 2>/dev/null +openssl x509 -req -sha256 -days 2 -in "$work/server.csr" -CA "$work/ca.pem" -CAkey "$work/ca.key" \ + -CAcreateserial -extfile "$work/server.ext" -out "$work/server.pem" 2>/dev/null + +cp /etc/hosts "$work/hosts" +printf '127.0.0.1 %s\n::1 %s\n' "$api_host" "$api_host" | sudo tee -a /etc/hosts > /dev/null +flush_dns + +python3 tool/stand_in_api.py --cert "$work/server.pem" --key "$work/server.key" 2> "$work/server.log" & +server_pid=$! + +# The host itself must reach the stand-in through the API host name before the +# simulator tries. +for _ in $(seq 50); do + if curl --silent --fail --cacert "$work/ca.pem" --request POST --data '{}' \ + "https://$api_host/v1/session/unilink/bridge-check/" > "$work/answer.json"; then + break + fi + sleep 0.2 +done +grep -q '"can_retry": false' "$work/answer.json" || { + echo "The stand-in API does not answer on https://$api_host" >&2 + exit 1 +} + +# Boot after the hosts change, then trust the certificate authority. +xcrun simctl shutdown "$udid" 2> /dev/null || true +xcrun simctl bootstatus "$udid" -b +xcrun simctl keychain "$udid" add-root-cert "$work/ca.pem" + +(cd example && flutter test integration_test/retry_blocked_test.dart -d "$udid" \ + --dart-define=DIDIT_STAND_IN_API=true) diff --git a/tool/stand_in_api.py b/tool/stand_in_api.py new file mode 100755 index 0000000..0010238 --- /dev/null +++ b/tool/stand_in_api.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""A stand-in for the verification API, for tool/ios_bridge_test.sh. + +It answers the workflow session request (POST /v1/session/unilink//) the +way the API answers a person whose earlier session was declined and who has no retries +left, and answers 404 to anything else. It logs each request's method, path and status +to stderr, never its query or body. +""" + +import argparse +import json +import socket +import ssl +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +UNILINK_PATH = "/v1/session/unilink/" + + +def retry_blocked_session(workflow_id): + return { + "session_id": "retry-blocked-session", + "session_token": "retry-blocked-token", + "url": "https://verify.didit.me/session/retry-blocked-token", + "status": "Declined", + "workflow_id": workflow_id, + "workflow_label": "Bridge test", + "requires_email": False, + "is_existing_session": True, + "can_retry": False, + "retry_attempts_used": 3, + "max_retry_attempts": 2, + "retry_window_days": None, + "retry_resets_at": None, + } + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length") or 0)) + path = urlsplit(self.path).path + if path.startswith(UNILINK_PATH): + self.reply(200, retry_blocked_session(path[len(UNILINK_PATH):].strip("/"))) + else: + self.reply(404, {"detail": "Not found."}) + + def do_GET(self): + self.reply(404, {"detail": "Not found."}) + + def reply(self, status, body): + data = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_request(self, code="-", size="-"): + sys.stderr.write(f"{self.command} {urlsplit(self.path).path} {code}\n") + + +class DualStackServer(ThreadingHTTPServer): + # The API host points at both 127.0.0.1 and ::1, so listen on both. + address_family = socket.AF_INET6 + + def server_bind(self): + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + super().server_bind() + + def get_request(self): + try: + return super().get_request() + except ssl.SSLError as error: + sys.stderr.write(f"TLS handshake failed: {error}\n") + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=443) + parser.add_argument("--cert", required=True, help="server certificate (PEM)") + parser.add_argument("--key", required=True, help="server private key (PEM)") + args = parser.parse_args() + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(args.cert, args.key) + server = DualStackServer(("::", args.port), Handler) + server.socket = context.wrap_socket(server.socket, server_side=True) + server.serve_forever() + + +if __name__ == "__main__": + main() From 652907805d14f635318edfe4cc75589536413c39 Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Thu, 8 Oct 2026 02:25:24 +0000 Subject: [PATCH 5/5] DID-5768: [H11] Flutter SDK: Bank and Location steps pass-through (continuation of DID-5660) Opened by a BotInc run. Delivery follows the workflow's review and merge policy. --- example/ios/Podfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 5b662a9..4d6c5cf 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -29,6 +29,6 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e -PODFILE CHECKSUM: ed31d62c92f6244acb75fd05044c648c8b8de30f +PODFILE CHECKSUM: a1ad4ffffd9b230cd409bdeae40aadb4c08d1b7b COCOAPODS: 1.16.2