Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -116,3 +122,17 @@ 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"

# 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
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ 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): `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=<an iPhone simulator>'` 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.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<key>NSCameraUsageDescription</key>
Expand Down Expand Up @@ -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
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to confirm where you are for this verification.</string>
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
<key>DiditLocationVerification</key>
<string>This verification needs your precise location once.</string>
</dict>
```
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:
Expand All @@ -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 `<manifest>` element):

```xml
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:node="remove" />
```

#### Runtime Permissions

The SDK handles Android runtime permission requests automatically. When the user reaches a step that requires camera access:
Expand Down Expand Up @@ -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<VerificationResult>`. The result is a sealed class — use pattern matching to determine the outcome.
Expand Down Expand Up @@ -458,6 +501,7 @@ Both `startVerification` and `startVerificationWithWorkflow` return a `Future<Ve
| `cameraAccessDenied` | Camera permission not granted |
| `notInitialized` | SDK not initialized (Android only) |
| `apiError` | API request failed |
| `retryBlocked` | Maximum verification attempts reached; the session comes back as `declined` |
| `unknown` | Other error with message |

### Complete Result Handling Example
Expand Down Expand Up @@ -597,6 +641,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
Expand Down
3 changes: 2 additions & 1 deletion android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
236 changes: 224 additions & 12 deletions android/src/test/kotlin/me/didit/sdk/sdk_flutter/SdkFlutterPluginTest.kt
Original file line number Diff line number Diff line change
@@ -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<Activity>()
private val result = mockk<MethodChannel.Result>(relaxed = true)
private val sdkState = MutableStateFlow<DiditSdkState>(DiditSdkState.Idle)
private val configuration = slot<Configuration>()
private val onResult = slot<(VerificationResult) -> Unit>()
private val delivered = mutableListOf<Any?>()
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<Any?>() }

plugin = SdkFlutterPlugin()
plugin.onAttachedToActivity(mockk<ActivityPluginBinding> {
every { activity } returns hostActivity
})
}

@AfterTest
fun tearDown() {
unmockkObject(DiditSdk)
Dispatchers.resetMain()
}

private fun startVerification(config: Map<String, Any> = 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<List<Any?>>(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<List<Any?>>(
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<List<Any?>>(
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<String, Any>()), 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() }
}
}
Loading
Loading