android/examples/build.gradle.kts.snippet
// Merge into the APP MODULE build.gradle.kts (e.g. app/build.gradle.kts), not the root one.
// Kotlin DSL. Requirements: AndroidX, minSdk >= 23, Kotlin >= 2.1.0.
// The Sumsub Maven repository itself goes in settings.gradle.kts — see settings.gradle.kts.snippet.
android {
defaultConfig {
minSdk = 23 // SDK requires API level 23+ — raise if lower
}
}
dependencies {
// ...app's existing dependencies stay as they are
// Pin one version and reuse it for every Sumsub artifact — base and modules
// MUST share the same version. Resolve the latest from the changelog; 1.45.1 shown.
val sumsubSdkVersion = "1.45.1"
implementation("com.sumsub.sns:idensic-mobile-sdk:$sumsubSdkVersion")
// Optional modules — uncomment only the ones confirmed in intake (same version):
// implementation("com.sumsub.sns:idensic-mobile-sdk-videoident:$sumsubSdkVersion") // VideoIdent
// implementation("com.sumsub.sns:idensic-mobile-sdk-eid:$sumsubSdkVersion") // German eID — separate PRIVATE repo; credentials from Sumsub support
// implementation("com.sumsub.sns:idensic-mobile-sdk-nfc:$sumsubSdkVersion") // NFC passport / eMRTD (MRTDReader)
//
// Device Intelligence (Fisherman) is bundled in the base SDK since 1.43.0 —
// no separate dependency needed on Android.
}
// Groovy DSL (build.gradle): `minSdk 23`, `def sumsubSdkVersion = "1.45.1"`,
// implementation "com.sumsub.sns:idensic-mobile-sdk:$sumsubSdkVersion"
// If the project uses a version catalog (gradle/libs.versions.toml), declare the
// version + libraries there and reference them the house way instead.
android/examples/settings.gradle.kts.snippet
// settings.gradle.kts — Kotlin DSL.
// The Sumsub SDK is published on Sumsub's own Maven repository, not Maven Central.
// Add the repository so Gradle can resolve `com.sumsub.sns:*`. Add it in exactly ONE
// place — wherever this project already declares repositories.
// MODERN projects (Gradle 7+) declare repositories centrally in settings.gradle.kts
// under dependencyResolutionManagement — add the maven line alongside google() / mavenCentral():
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) // keep whatever mode the project already has — don't add or change it
repositories {
google()
mavenCentral()
maven { url = uri("https://maven.sumsub.com/repository/maven-public/") } // Sumsub SDK
// ONLY if the eID module was selected in intake: the eID artifact lives in
// Sumsub's PRIVATE repo. Credentials come from Sumsub support — put them in
// ~/.gradle/gradle.properties (sumsubRepoUsername / sumsubRepoPassword);
// NEVER hardcode or commit them.
// maven {
// url = uri("https://maven.sumsub.com/repository/maven-private/")
// credentials {
// username = providers.gradleProperty("sumsubRepoUsername").getOrElse("")
// password = providers.gradleProperty("sumsubRepoPassword").getOrElse("")
// }
// }
}
}
// OLDER projects without dependencyResolutionManagement: add the same blocks to the
// `allprojects { repositories { ... } }` block in the ROOT build.gradle.kts instead.
//
// Groovy DSL (settings.gradle):
// maven { url "https://maven.sumsub.com/repository/maven-public/" }
// // eID only:
// maven {
// url "https://maven.sumsub.com/repository/maven-private/"
// credentials {
// username = providers.gradleProperty("sumsubRepoUsername").getOrElse("")
// password = providers.gradleProperty("sumsubRepoPassword").getOrElse("")
// }
// }
android/examples/SumsubIntegration.kt
//
// SumsubIntegration.kt
// Sumsub Mobile SDK glue for Android — coroutine-first, architecture-agnostic.
//
// Generated by the sumsub-integrate-msdk skill. This file holds ONLY the reusable
// Sumsub glue; it does not own threading, lifecycle, or UI. You drive it from your
// own ViewModel / coroutine scope (see the launch stage for MVVM, MVI & Compose
// wiring). The App Token / secret must NEVER ship in the app — only the short-lived
// access token your backend signs is used here.
//
// Import paths match the current SDK package layout (com.sumsub.sns.core.*) —
// let the IDE confirm/adjust them if your version differs.
//
package com.example.app.sumsub
import android.app.Activity
import android.util.Log
import com.sumsub.sns.core.SNSMobileSDK
import com.sumsub.sns.core.data.listener.TokenExpirationHandler
import com.sumsub.sns.core.data.model.SNSCompletionResult
import com.sumsub.sns.core.data.model.SNSException
import com.sumsub.sns.core.data.model.SNSSDKState
import kotlinx.coroutines.runBlocking
import java.util.Locale
/**
* The seam into your app's architecture. Implement it in your data/domain layer — a
* repository, a use-case, a Retrofit/Ktor service wrapper, whatever your app
* already uses for I/O — to return a fresh Sumsub access token signed by your backend
* (`POST /resources/accessTokens`, App Token-signed server-side).
*
* It is a `suspend` function: do the network call with your normal stack and dispatcher
* (e.g. `withContext(Dispatchers.IO) { api.accessToken() }`). The launcher below calls it
* both for the initial launch (from your ViewModel's scope) and for mid-session refresh.
*/
fun interface SumsubTokenProvider {
suspend fun fetchAccessToken(): String
}
/**
* Sumsub SDK launcher. Construct it with your [SumsubTokenProvider] (inject the
* same repository instance your ViewModel uses), then call [present] once you already
* hold a token — typically from your ViewModel-driven event, on the main thread.
*/
class SumsubLauncher(private val tokenProvider: SumsubTokenProvider) {
/**
* Build and launch the SDK. Call on the MAIN thread with the hosting [activity] and a
* token you already fetched (your ViewModel fetched it in `viewModelScope`).
*/
fun present(activity: Activity, accessToken: String) {
val sdk = SNSMobileSDK.Builder(activity)
.withAccessToken(accessToken, onTokenExpiration = tokenExpirationHandler)
.withHandlers(
// Optional progress tracking — surface it to your state holder if useful.
onStateChanged = { newState: SNSSDKState, prevState: SNSSDKState ->
Log.d("Sumsub", "State: $prevState -> $newState")
},
// Fires when the flow closes. UX only — NOT the source of truth. The
// authoritative verdict comes from your backend (webhook + applicant GET).
onCompleted = { result: SNSCompletionResult, _: SNSSDKState ->
when (result) {
is SNSCompletionResult.SuccessTermination ->
Log.d("Sumsub", "Flow finished")
is SNSCompletionResult.AbnormalTermination ->
Log.e("Sumsub", "Flow closed with error", result.exception)
}
},
onError = { e: SNSException -> Log.e("Sumsub", "SDK error", e) },
)
// Device locale by default; pass a fixed Locale("en") etc. to force a language.
.withLocale(Locale.getDefault())
// If you style the SDK, attach the theme here — see sumsub-theme-msdk skill:
// .withTheme(sumsubTheme(activity))
.build()
sdk.launch()
}
/**
* REQUIRED token refresh. The SDK calls `onTokenExpired()` SYNCHRONOUSLY on a
* background thread and blocks on the return value, so we bridge the suspend provider
* with `runBlocking` — this is the one place it is correct: we are already off the
* main thread and MUST return a value. Returning null aborts the session.
*/
private val tokenExpirationHandler = object : TokenExpirationHandler {
override fun onTokenExpired(): String? = runBlocking {
runCatching { tokenProvider.fetchAccessToken() }
.onFailure { Log.e("Sumsub", "Token refresh failed", it) }
.getOrNull()
}
}
}
android/stages/1-scan.md
# Step 1 (Android) — Scan the project (read-only)
> **You're here if:** the project was detected as **Android** (Gradle / Kotlin), and intake is done.
> **Prereqs:** none — this is read-only; no approval needed.
After intake, inspect without editing anything:
- **Bail early if not native Android.** `package.json` with `react-native`, or a `pubspec.yaml` → cross-platform
wrapper, not supported — stop and tell the user. No `build.gradle[.kts]` / `AndroidManifest.xml`, or no module
applying `com.android.application` → stop; this skill needs a real Android app project.
- **Find the app module** — the module whose `build.gradle[.kts]` applies the `com.android.application` plugin (usually
`app/`).
- **One** application module → use it; confirm to the user ("I'll use module **app**").
- **Multiple** application modules → ask which one.
- **Build language:** note Groovy (`build.gradle`) vs Kotlin DSL (`build.gradle.kts`), and whether the project uses a
**version catalog** (`gradle/libs.versions.toml`) — you'll match both when editing.
- **Where repositories are declared:** `dependencyResolutionManagement { repositories { … } }` in
`settings.gradle[.kts]` (modern, Gradle 7+) **or** `allprojects { repositories { … } }` in the root `build.gradle` (
older). You'll add the Sumsub Maven repo in whichever the project uses — not both.
- **`minSdk`** (in the app module's `defaultConfig`) — must be **≥ 23**. If lower, surface the value and tell the user
it must be raised to 23 to proceed; fold the bump into the install approval. If the user refuses, stop — the SDK won't
build below 23.
- **Kotlin version** — must be **≥ 2.1.0**. If lower, flag it; older Kotlin fails to compile against the SDK.
- **UI toolkit:** note whether the launch screen uses Jetpack Compose or Views — it only affects the launch site (Step
4), not the rest.
Summarise findings for the user before proceeding.
## Next
- Install the framework → [`2-install-dependencies.md`](2-install-dependencies.md)
android/stages/2-install-dependencies.md
# Step 2 (Android) — Install via Gradle
> **You're here if:** the project is Android (per Step 1).
> **Prereqs:** Step 1 done — you know the app module, build language, and where repos are declared.
Two edits — the repository, then the dependency. Tell the user exactly what you'll add and why, ask once, and edit only
after explicit approval.
1. **Maven repository.** The SDK is on Sumsub's own Maven repo, not Maven Central, so resolution fails without it. Add
`maven { url "https://maven.sumsub.com/repository/maven-public/" }` in the **one** place the project declares
repositories (from Step 1): `dependencyResolutionManagement` in `settings.gradle[.kts]` (modern) or
`allprojects { repositories }` in the root `build.gradle` (older). Full shape — both styles, Groovy + Kotlin DSL —
in [`../examples/settings.gradle.kts.snippet`](../examples/settings.gradle.kts.snippet).
**If eID was selected in intake (Q3), also add the private repo** in the same repositories block:
`https://maven.sumsub.com/repository/maven-private/` with `credentials { username / password }` read from Gradle
properties (shape in the same snippet). The credentials come from Sumsub support — tell the user to request them
and put the values in `~/.gradle/gradle.properties` (`sumsubRepoUsername` / `sumsubRepoPassword`); **never**
hardcode or commit them.
2. **Dependency.** In the **app module** build file (match Groovy vs `.kts`), add the base implementation plus a line
per module confirmed in intake (Q3). If the project uses a **version catalog** (`gradle/libs.versions.toml`, per
Step 1), declare the version and libraries there and reference them the house way instead of inlining coordinates.
Pin one version and reuse it for every Sumsub artifact — base and modules **must** share the same version. Full
shape in [`../examples/build.gradle.kts.snippet`](../examples/build.gradle.kts.snippet):
- Base: `implementation "com.sumsub.sns:idensic-mobile-sdk:$version"`
- VideoIdent: `…:idensic-mobile-sdk-videoident:$version`
- EID: `…:idensic-mobile-sdk-eid:$version` — resolves from the **private repo added in edit 1**; without that repo
and its credentials the dependency won't resolve.
- NFC (MRTDReader): `…:idensic-mobile-sdk-nfc:$version` — on MSDK ≥ 1.40.0 the build may also need a packaging
exclusion in the app module: `packaging { resources.excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" }`.
Add it only if the build fails on that duplicate resource.
- **Device Intelligence (Fisherman)** is **bundled in the base since 1.43.0** — no separate dependency on Android.
Resolve the latest version from the [SDK changelog](https://docs.sumsub.com/docs/changelog-android) (or ask the
user); don't hardcode a stale one.
3. **`minSdk ≥ 23`** — if Step 1 found it lower, raise it in the app module's `defaultConfig` as part of this same
change-set.
After editing, ask: "Should I trigger a Gradle sync / build now, or would you prefer to in Android Studio?" Run any
Gradle command **only with explicit permission**.
## Next
- Create the integration code → [`3-integration-code.md`](3-integration-code.md)
android/stages/3-integration-code.md
# Step 3 (Android) — Integration code
> **You're here if:** the project is Android — this is the core of the integration.
> **Prereqs:** framework installed (Step 2). No permissions step — the SDK declares and requests its own.
Create a new Kotlin file holding the reusable Sumsub glue, named `SumsubIntegration.kt`
(template: [`../examples/SumsubIntegration.kt`](../examples/SumsubIntegration.kt)). It defines two
small pieces and **owns no threading, lifecycle, or UI** — that stays in the host's
architecture (Step 4):
1. **`SumsubTokenProvider`** — a `fun interface` with `suspend fun fetchAccessToken(): String`.
This is the seam into the app's own architecture: the host implements it in its
**data/domain layer** (a repository, a use-case, a Retrofit/Ktor service) to return a
fresh backend-signed access token. Because it's `suspend`, it composes with the app's
coroutines and dispatchers — no callbacks, no manual threading.
2. **`SumsubLauncher(tokenProvider)`** — the only Sumsub-specific class. `present(activity, token)`
builds the SDK via `SNSMobileSDK.Builder(activity)`, wires the handlers, and calls
`launch()` (the SDK starts its own Activity). It deliberately does **not** fetch the
initial token or hold a `CoroutineScope` — the host's `viewModelScope` /
`lifecycleScope` does, so cancellation tracks the host lifecycle.
**Fit it to the host architecture (don't reinvent it).** From the Step 1 scan you know
whether the app is MVVM, MVI, etc. Place `SumsubIntegration.kt` in a sensible package, then in
Step 4 wire it the app's own way — the `SumsubTokenProvider` is implemented by the app's
repository (inject it with whatever DI the project uses: Hilt, Koin, or a manual
factory), and the launch is triggered from a ViewModel. Name the file in the change-set
approval before creating it:
> "I'll create `SumsubIntegration.kt` (`SumsubTokenProvider` + `SumsubLauncher`) in `<package>`,
> and wire `SumsubTokenProvider` to your `<repository>` — apply?"
**No separate "wire into target" step.** Unlike iOS (pbxproj), Gradle compiles any `.kt`
under a source set (`src/main/java|kotlin/...`) automatically.
Do **not** edit existing app code beyond the launch site the user named in intake (Q1)
and the token-provider wiring. Never touch unrelated files.
## The required token-expiration handler
The `TokenExpirationHandler` is **required** — without it the flow hangs when the token
expires mid-session. The launcher implements it for you by calling the same
`SumsubTokenProvider`.
> **Gotcha — the SDK callback is SYNCHRONOUS.** `onTokenExpired(): String?` must **return**
> the fresh token, on a **background thread**, and the SDK blocks on the return value. The
> template bridges the `suspend` provider with `runBlocking { tokenProvider.fetchAccessToken() }`
> — this is the **one** place `runBlocking` is correct (already off the main thread, and a
> value must be returned). Do not try to make it async or hop to the main thread. Returning
> `null` aborts the session.
## Optional handlers
Registered on the builder via `withHandlers(...)` (or the dedicated `with*Handler`). Forward
them to your state holder (ViewModel `StateFlow` / MVI state) if the UI needs progress:
| Handler | Signature | Fires when |
|------------------|--------------------------------------------------------------|----------------------------------------------------|
| `onStateChanged` | `(SNSSDKState, SNSSDKState) -> Unit` *(newState, prevState)* | SDK state changes — track progress |
| `onCompleted` | `(SNSCompletionResult, SNSSDKState) -> Unit` | Flow closes — UX only, **not** the source of truth |
| `onError` | `(SNSException) -> Unit` | SDK throws |
`SNSCompletionResult` is `SuccessTermination` or `AbnormalTermination(exception)`.
Key `SNSSDKState` values: `Ready`, `Initial`, `Incomplete`, `Pending`, `Approved`,
`TemporarilyDeclined`, `FinallyRejected`, and the sealed `Failed.*` family
(`Failed.Unauthorized`, `Failed.NetworkError`, …).
## Token source (no backend yet)
`SumsubTokenProvider` is where the real backend call goes. If the user has no backend
endpoint yet, implement it to return a hardcoded sandbox token for an initial end-to-end
test, and say so explicitly:
> "I've wired `SumsubTokenProvider` to your repository. Until that endpoint exists, have it
> return a hardcoded sandbox access token so you can see the flow; replace it with the real
> `suspend` backend call before shipping."
## Next
- Wire the launch point → [`4-launch.md`](4-launch.md)
android/stages/4-launch.md
# Step 4 (Android) — Launch point
> **You're here if:** the project is Android and `SumsubIntegration.kt` is created.
> **Prereqs:** `SumsubTokenProvider` + `SumsubLauncher` exist and are under a source set (Step 3).
Wire the launch into the screen the user named in intake (Q1), **the app's own way**.
The shape is always the same and matches any architecture (MVVM / MVI / plain):
1. The user triggers verification (button tap / intent).
2. A **ViewModel** fetches the token in `viewModelScope` (calling your `SumsubTokenProvider`
repository) and emits a **one-shot effect** carrying the token.
3. The **screen** collects that effect lifecycle-safely and calls
`launcher.present(activity, token)` — `present` needs an `Activity`, which a ViewModel
must not hold, so the launch itself stays in the view layer.
Inject the **same** `SumsubTokenProvider` instance (your repository) into both the
ViewModel and the `SumsubLauncher` — via the project's DI (Hilt/Koin) or a factory.
Show the call sites before writing.
## ViewModel (MVVM) — owns the async work
```kotlin
class VerificationViewModel(
private val tokenProvider: SumsubTokenProvider, // your repository, injected
) : ViewModel() {
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _effects = Channel<VerificationEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
fun onVerifyClicked() {
viewModelScope.launch {
_isLoading.value = true
val token = runCatching { tokenProvider.fetchAccessToken() }.getOrNull()
_isLoading.value = false
_effects.send(
if (token != null) VerificationEffect.Launch(token) else VerificationEffect.Error
)
}
}
}
sealed interface VerificationEffect {
data class Launch(val accessToken: String) : VerificationEffect
data object Error : VerificationEffect
}
```
## Collect the effect and launch — Views (Activity / Fragment)
```kotlin
private val viewModel: VerificationViewModel by viewModels { /* your factory / DI */ }
private val launcher = SumsubLauncher(/* same SumsubTokenProvider via DI */)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
verifyButton.setOnClickListener { viewModel.onVerifyClicked() }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { // collect only while visible
viewModel.effects.collect { effect ->
when (effect) {
is VerificationEffect.Launch -> launcher.present(this@HomeActivity, effect.accessToken)
VerificationEffect.Error -> showError()
}
}
}
}
}
```
In a **Fragment**, use `viewLifecycleOwner.lifecycleScope` and pass `requireActivity()` to
`present(...)`.
## Collect the effect and launch — Jetpack Compose
```kotlin
@Composable
fun VerifyScreen(viewModel: VerificationViewModel = viewModel()) {
val activity = LocalContext.current.findActivity()
val launcher = remember { SumsubLauncher(/* token provider via DI */) }
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { // collect only while visible
viewModel.effects.collect { effect ->
when (effect) {
is VerificationEffect.Launch -> launcher.present(activity, effect.accessToken)
VerificationEffect.Error -> { /* show a snackbar / error state */ }
}
}
}
}
Button(onClick = viewModel::onVerifyClicked, enabled = !isLoading) {
Text(if (isLoading) "Starting…" else "Verify")
}
}
// LocalContext.current is often a ContextWrapper (themed context), not the Activity —
// a bare `as Activity` cast can crash. Unwrap instead:
private tailrec fun Context.findActivity(): Activity = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> error("No Activity in the context chain")
}
```
## MVI variant
Same pieces, folded into the app's MVI contract: `onVerifyClicked()` becomes an
`Intent.StartVerification` reduced in the store; `isLoading` is part of the single state;
`VerificationEffect.Launch` is a one-shot **side-effect** (not state — never keep a token
in replayable state). The view collects effects and calls `launcher.present(...)`. If the
app surfaces SDK progress, forward the launcher's `onStateChanged` / `onCompleted` into new
intents so the reducer owns it.
## Next
- Core flow is done — return to **Handoff** in [`../../SKILL.md`](../../SKILL.md).
examples/Info.plist.snippet
<!-- Add inside the target's Info.plist <dict>. -->
<!-- If the target has NO Info.plist file (GENERATE_INFOPLIST_FILE = YES), add -->
<!-- these as build settings instead, e.g. INFOPLIST_KEY_NSCameraUsageDescription. -->
<key>NSCameraUsageDescription</key>
<string>We need the camera to capture your documents and selfie.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need the microphone for video identification.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need photo library access to upload an existing document photo.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to confirm where you are verifying from.</string>
<!-- ─── NFC modules (MRTDReader and/or EID) — add the two keys below ONLY if such a -->
<!-- module was confirmed in intake. Also enable the "Near Field Communication Tag -->
<!-- Reading" capability (writes the NFC entitlement to .entitlements). -->
<key>NFCReaderUsageDescription</key>
<string>Let us scan the document for more precise recognition</string>
<!-- Include the AIDs for each CONFIRMED module; if both, the shared A0000002471001 -->
<!-- appears only once (union of the two lists): -->
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
<string>A0000002471001</string> <!-- MRTDReader + EID (shared) -->
<string>A0000002472001</string> <!-- MRTDReader only -->
<string>00000000000000</string> <!-- MRTDReader only -->
<string>E80704007F00070302</string> <!-- EID only -->
</array>
<!-- Add the `audio` string to the `UIBackgroundModes` array only if VideoIdent module is CONFIRMED in intake:-->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
examples/Package.swift.snippet
// Package.swift structure — add IdensicMobileSDK (and any confirmed modules) to your target.
// Replace X.Y.Z with the latest tag — see stages/2b-install-spm.md for how to resolve it.
dependencies: [
.package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS.git", from: "X.Y.Z"),
// Optional modules (each its own package) — uncomment only the ones confirmed in intake:
// .package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS-MRTDReader.git", from: "X.Y.Z"), // NFC
// .package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS-VideoIdent.git", from: "X.Y.Z"), // Video ID
// .package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS-EID.git", from: "X.Y.Z"), // eID
// .package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS-Fisherman.git", from: "X.Y.Z"), // Device Intelligence
],
targets: [
.target(name: "YourApp", dependencies: [ // YourApp = your real target
.product(name: "IdensicMobileSDK", package: "IdensicMobileSDK-iOS"),
// add the product for each module package enabled above:
// .product(name: "IdensicMobileSDK_MRTDReader", package: "IdensicMobileSDK-iOS-MRTDReader"),
// .product(name: "IdensicMobileSDK_VideoIdent", package: "IdensicMobileSDK-iOS-VideoIdent"),
// .product(name: "IdensicMobileSDK_EID", package: "IdensicMobileSDK-iOS-EID"),
// .product(name: "IdensicMobileSDK_Fisherman", package: "IdensicMobileSDK-iOS-Fisherman"),
])
]
examples/Podfile.snippet
# Podfile shape for IdensicMobileSDK. You're merging these pieces into the project's
# EXISTING Podfile (this skill doesn't create one — no Podfile → use SPM).
# Sources go at the very top, OUTSIDE any target. IdensicMobileSDK is published in
# Sumsub's spec repo, so both are required (once any custom source is added, the default
# cdn source must be declared explicitly too):
source 'https://cdn.cocoapods.org/'
source 'https://github.com/sumsub/Specs.git'
platform :ios, '13.0' # SDK minimum; must match (or be below) the project's deployment target, and be ≥ 13.0
target 'YourApp' do # ← the project's EXISTING app target — use its real name, don't add a new one
use_frameworks! # IdensicMobileSDK is a dynamic Swift framework; add if not already declared (here or at the Podfile root)
# ...app's existing pods stay as they are
# Base required module
pod 'IdensicMobileSDK'
# Optional modules — use only the ones confirmed in intake:
# pod 'IdensicMobileSDK/MRTDReader' # NFC passport / eMRTD
# pod 'IdensicMobileSDK/VideoIdent' # Video Identification
# pod 'IdensicMobileSDK/EID' # German eID
# pod 'IdensicMobileSDK/Fisherman' # Device Intelligence
end
examples/SumsubSwiftUI.swift
//
// SumsubSwiftUI.swift
// Optional SwiftUI launch helper for the Sumsub Mobile SDK.
//
// Generated by the sumsub-integrate-msdk skill. A thin ObservableObject that forwards
// to SumsubVerification.start() — the SDK presents itself. Optional: a view can hold
// SumsubVerification directly instead.
//
// Usage:
// struct MyView: View {
// @StateObject private var sumsub = SumsubPresenter() // survives the async token fetch
// var body: some View {
// Button("Verify") { sumsub.launch() }
// }
// }
//
import SwiftUI
final class SumsubPresenter: ObservableObject {
private let verification = SumsubVerification()
/// Call on the trigger (e.g. button tap). The SDK presents itself over the key
/// window's root view controller and tears itself down on finish/close.
func launch() {
verification.start()
}
}
examples/SumsubVerification.swift
//
// SumsubVerification.swift
// Minimal Sumsub Mobile SDK integration for iOS.
//
// Generated by the sumsub-integrate-msdk skill. Adapt the token fetch to your
// backend. The App Token / secret must NEVER ship in the app — only the
// short-lived access token returned by your server is used here.
//
// Same launch path for UIKit and SwiftUI: the SDK presents itself.
//
import UIKit
import IdensicMobileSDK
// Swift 6 strict concurrency: mark this class `@MainActor` — see
// references/swift6-concurrency.md.
final class SumsubVerification {
/// Launch the flow. Presents from `presenter`, or from the key window's root
/// VC when `presenter` is nil (the usual case for SwiftUI — pass nil).
/// `onDismiss` fires when the user closes the flow.
func start(from presenter: UIViewController? = nil, onDismiss: (() -> Void)? = nil) {
makeSDK(onDismiss: onDismiss) { sdk in
if let presenter {
sdk.present(from: presenter)
} else {
sdk.present() // present from the key window's root VC
}
}
}
/// Shared async orchestration: fetch the token, then build the SDK on the main
/// thread and present it. Don't retain `sdk` — once presented, `mainVC` keeps the
/// SDK alive for the whole flow and both are released together on dismiss.
private func makeSDK(onDismiss: (() -> Void)?,
_ ready: @escaping (SNSMobileSDK) -> Void) {
fetchAccessToken { [weak self] token in
guard let self, let token else {
print("Sumsub: no access token from backend")
return
}
DispatchQueue.main.async {
guard let sdk = self.configuredSDK(
token: token,
onDismiss: onDismiss
) else {
return
}
ready(sdk)
}
}
}
/// Builds a ready, fully-wired SDK. Call on the **main thread** — init touches UIKit.
/// Returns `nil` if init failed (`verboseStatus` says why). `[weak self]` keeps the
/// token-refresh handler from retaining this launch object.
private func configuredSDK(token: String, onDismiss: (() -> Void)?) -> SNSMobileSDK? {
let sdk = SNSMobileSDK(accessToken: token)
// A failed init still returns an object — verboseStatus says why.
guard sdk.isReady else {
print("Sumsub init failed: \(sdk.verboseStatus)")
return nil
}
// REQUIRED: refresh the token when it expires, or the flow stalls.
sdk.tokenExpirationHandler { [weak self] onComplete in
self?.fetchAccessToken { newToken in
onComplete(newToken) // pass nil to fail the session
}
}
// Optional progress tracking.
sdk.onStatusDidChange { sdk, prevStatus in
print("Sumsub status: \(sdk.description(for: prevStatus)) "
+ "→ \(sdk.description(for: sdk.status))")
}
sdk.onDidDismiss { _ in
// Authoritative verdict comes from your backend (webhook + applicant GET).
onDismiss?()
}
// If you style the SDK, set the theme here, before handing it back:
// sdk.theme = SumsubTheme() // see sumsub-theme-msdk
return sdk
}
/// Replace with a real call to YOUR backend, which signs
/// POST /resources/accessTokens with the Sumsub App Token.
private func fetchAccessToken(completion: @escaping (String?) -> Void) {
// URLSession.shared.dataTask(...) { ... completion(token) }.resume()
completion(nil)
}
}
ios/examples/Info.plist.snippet
<!-- Add inside the target's Info.plist <dict>. -->
<!-- If the target has NO Info.plist file (GENERATE_INFOPLIST_FILE = YES), add -->
<!-- these as build settings instead, e.g. INFOPLIST_KEY_NSCameraUsageDescription. -->
<key>NSCameraUsageDescription</key>
<string>We need the camera to capture your documents and selfie.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need the microphone for video identification.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need photo library access to upload an existing document photo.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to confirm where you are verifying from.</string>
<!-- ─── NFC modules (MRTDReader and/or EID) — add the two keys below ONLY if such a -->
<!-- module was confirmed in intake. Also enable the "Near Field Communication Tag -->
<!-- Reading" capability (writes the NFC entitlement to .entitlements). -->
<key>NFCReaderUsageDescription</key>
<string>Let us scan the document for more precise recognition</string>
<!-- Include the AIDs for each CONFIRMED module; if both, the shared A0000002471001 -->
<!-- appears only once (union of the two lists): -->
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
<string>A0000002471001</string> <!-- MRTDReader + EID (shared) -->
<string>A0000002472001</string> <!-- MRTDReader only -->
<string>00000000000000</string> <!-- MRTDReader only -->
<string>E80704007F00070302</string> <!-- EID only -->
</array>
<!-- Add the `audio` string to the `UIBackgroundModes` array only if VideoIdent module is CONFIRMED in intake:-->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
ios/examples/Package.swift.snippet
// Package.swift structure — add IdensicMobileSDK (and any confirmed modules) to your target.
// Replace X.Y.Z with the latest tag — see stages/2b-install-spm.md for how to resolve it.
dependencies: [
.package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS.git", from: "X.Y.Z"),
// Optional modules (each its own package) — uncomment only the ones confirmed in intake:
// .package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS-MRTDReader.git", from: "X.Y.Z"), // NFC
// .package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS-VideoIdent.git", from: "X.Y.Z"), // Video ID
// .package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS-EID.git", from: "X.Y.Z"), // eID
// .package(url: "https://github.com/sumsub/IdensicMobileSDK-iOS-Fisherman.git", from: "X.Y.Z"), // Device Intelligence
],
targets: [
.target(name: "YourApp", dependencies: [ // YourApp = your real target
.product(name: "IdensicMobileSDK", package: "IdensicMobileSDK-iOS"),
// add the product for each module package enabled above:
// .product(name: "IdensicMobileSDK_MRTDReader", package: "IdensicMobileSDK-iOS-MRTDReader"),
// .product(name: "IdensicMobileSDK_VideoIdent", package: "IdensicMobileSDK-iOS-VideoIdent"),
// .product(name: "IdensicMobileSDK_EID", package: "IdensicMobileSDK-iOS-EID"),
// .product(name: "IdensicMobileSDK_Fisherman", package: "IdensicMobileSDK-iOS-Fisherman"),
])
]
ios/examples/Podfile.snippet
# Podfile shape for IdensicMobileSDK. You're merging these pieces into the project's
# EXISTING Podfile (this skill doesn't create one — no Podfile → use SPM).
# Sources go at the very top, OUTSIDE any target. IdensicMobileSDK is published in
# Sumsub's spec repo, so both are required (once any custom source is added, the default
# cdn source must be declared explicitly too):
source 'https://cdn.cocoapods.org/'
source 'https://github.com/sumsub/Specs.git'
platform :ios, '13.0' # SDK minimum; must match (or be below) the project's deployment target, and be ≥ 13.0
target 'YourApp' do # ← the project's EXISTING app target — use its real name, don't add a new one
use_frameworks! # IdensicMobileSDK is a dynamic Swift framework; add if not already declared (here or at the Podfile root)
# ...app's existing pods stay as they are
# Base required module
pod 'IdensicMobileSDK'
# Optional modules — use only the ones confirmed in intake:
# pod 'IdensicMobileSDK/MRTDReader' # NFC passport / eMRTD
# pod 'IdensicMobileSDK/VideoIdent' # Video Identification
# pod 'IdensicMobileSDK/EID' # German eID
# pod 'IdensicMobileSDK/Fisherman' # Device Intelligence
end
ios/examples/SumsubSwiftUI.swift
//
// SumsubSwiftUI.swift
// Optional SwiftUI launch helper for the Sumsub Mobile SDK.
//
// Generated by the sumsub-integrate-msdk skill. A thin ObservableObject that forwards
// to SumsubVerification.start() — the SDK presents itself. Optional: a view can hold
// SumsubVerification directly instead.
//
// Usage:
// struct MyView: View {
// @StateObject private var sumsub = SumsubPresenter() // survives the async token fetch
// var body: some View {
// Button("Verify") { sumsub.launch() }
// }
// }
//
import SwiftUI
final class SumsubPresenter: ObservableObject {
private let verification = SumsubVerification()
/// Call on the trigger (e.g. button tap). The SDK presents itself over the key
/// window's root view controller and tears itself down on finish/close.
func launch() {
verification.start()
}
}
ios/examples/SumsubVerification.swift
//
// SumsubVerification.swift
// Minimal Sumsub Mobile SDK integration for iOS.
//
// Generated by the sumsub-integrate-msdk skill. Adapt the token fetch to your
// backend. The App Token / secret must NEVER ship in the app — only the
// short-lived access token returned by your server is used here.
//
// Same launch path for UIKit and SwiftUI: the SDK presents itself.
//
import UIKit
import IdensicMobileSDK
// Swift 6 strict concurrency: mark this class `@MainActor` — see
// references/swift6-concurrency.md.
final class SumsubVerification {
/// Launch the flow. Presents from `presenter`, or from the key window's root
/// VC when `presenter` is nil (the usual case for SwiftUI — pass nil).
/// `onDismiss` fires when the user closes the flow.
func start(from presenter: UIViewController? = nil, onDismiss: (() -> Void)? = nil) {
makeSDK(onDismiss: onDismiss) { sdk in
if let presenter {
sdk.present(from: presenter)
} else {
sdk.present() // present from the key window's root VC
}
}
}
/// Shared async orchestration: fetch the token, then build the SDK on the main
/// thread and present it. Don't retain `sdk` — once presented, `mainVC` keeps the
/// SDK alive for the whole flow and both are released together on dismiss.
private func makeSDK(onDismiss: (() -> Void)?,
_ ready: @escaping (SNSMobileSDK) -> Void) {
fetchAccessToken { [weak self] token in
guard let self, let token else {
print("Sumsub: no access token from backend")
return
}
DispatchQueue.main.async {
guard let sdk = self.configuredSDK(
token: token,
onDismiss: onDismiss
) else {
return
}
ready(sdk)
}
}
}
/// Builds a ready, fully-wired SDK. Call on the **main thread** — init touches UIKit.
/// Returns `nil` if init failed (`verboseStatus` says why). `[weak self]` keeps the
/// token-refresh handler from retaining this launch object.
private func configuredSDK(token: String, onDismiss: (() -> Void)?) -> SNSMobileSDK? {
let sdk = SNSMobileSDK(accessToken: token)
// A failed init still returns an object — verboseStatus says why.
guard sdk.isReady else {
print("Sumsub init failed: \(sdk.verboseStatus)")
return nil
}
// REQUIRED: refresh the token when it expires, or the flow stalls.
sdk.tokenExpirationHandler { [weak self] onComplete in
self?.fetchAccessToken { newToken in
onComplete(newToken) // pass nil to fail the session
}
}
// Optional progress tracking.
sdk.onStatusDidChange { sdk, prevStatus in
print("Sumsub status: \(sdk.description(for: prevStatus)) "
+ "→ \(sdk.description(for: sdk.status))")
}
sdk.onDidDismiss { _ in
// Authoritative verdict comes from your backend (webhook + applicant GET).
onDismiss?()
}
// If you style the SDK, set the theme here, before handing it back:
// sdk.theme = SumsubTheme() // see sumsub-theme-msdk
return sdk
}
/// Replace with a real call to YOUR backend, which signs
/// POST /resources/accessTokens with the Sumsub App Token.
private func fetchAccessToken(completion: @escaping (String?) -> Void) {
// URLSession.shared.dataTask(...) { ... completion(token) }.resume()
completion(nil)
}
}
ios/references/pbxproj-editing.md
# Registering a source file in a classic `project.pbxproj`
When the target does **not** use synchronized groups (no
`PBXFileSystemSynchronizedRootGroup` in `project.pbxproj`), a new `.swift` file must
be registered explicitly. Prefer the Ruby `xcodeproj` gem if available — it generates valid UUIDs; hand-edit only as fallback. To hand-edit, add the four entries by mirroring an existing sibling file:
1. a `PBXBuildFile`,
2. a `PBXFileReference`,
3. a child in the group, and
4. an entry in the target's `Sources` build phase.
Then validate with `plutil -lint project.pbxproj`. Do this yourself — do not ask the
user to drag the file in via Xcode.
> This is the canonical place for source-file registration. The theming skill
> (`sumsub-theme-msdk`) creates `SumsubTheme.swift` and wires it in the same way.
ios/references/spm-pbxproj.md
# Adding an SPM package to an Xcode project's `project.pbxproj`
When the project is an Xcode project **without** a `Package.swift`, the SPM package
reference lives inside `project.pbxproj`. There is no first-party Apple CLI to add it,
but the file is editable.
- **Preferred (automatable):** edit `*.xcodeproj/project.pbxproj` directly to add an
`XCRemoteSwiftPackageReference` (the repo URL + version rule), an
`XCSwiftPackageProductDependency` for `IdensicMobileSDK`, and reference that product
in the app target's `Frameworks` build phase + `packageProductDependencies`. Mirror
an existing package entry if the project already has one. After editing, Xcode
resolves and writes `Package.resolved` on next open. If the Ruby `xcodeproj` gem is
available, prefer it over hand-editing — it generates valid UUIDs for you. **Always
show the diff and ask before editing the pbxproj**, then have the user open Xcode to
let it resolve.
- **Fallback (manual):** if pbxproj editing is risky for this project (unusual layout,
no template entry to mirror), instruct the user to add it via *File → Add Package
Dependencies* in Xcode, paste the URL, and pick the `IdensicMobileSDK` product.
ios/references/swift6-concurrency.md
# Swift 6 / strict concurrency
> Applies when the project builds in **Swift 6 mode** (or has "default actor isolation = MainActor"). Detect from `SWIFT_VERSION` / `SWIFT_STRICT_CONCURRENCY` in build settings before assuming.
The SDK's callbacks are **nonisolated**, so the compiler will reject capturing main-actor state in them — e.g. *"Task-isolated 'onDismiss' is captured by a main actor-isolated closure"*.
- Mark the launch class `@MainActor`.
- Inside SDK callbacks, hop explicitly before touching app/UI state: `sdk.onDidDismiss { _ in Task { @MainActor in onDismiss?() } }` (or `DispatchQueue.main.async { … }`, as in the SwiftUI presenter [`../examples/SumsubSwiftUI.swift`](../examples/SumsubSwiftUI.swift)).
Applies when writing the integration code — the launch class and its SDK callbacks ([`4-integration-code.md`](../stages/4-integration-code.md)) — and to the SwiftUI presenter's binding hop ([`6b-launch-swiftui.md`](../stages/6b-launch-swiftui.md)).
ios/stages/1-scan.md
# Step 1 — Scan the project (read-only)
> **You're here if:** intake is done (the three questions answered).
> **Prereqs:** none — this is read-only; no approval needed.
After intake, inspect without editing anything:
- **Bail early if not native iOS.** `package.json` with `react-native`, or a `pubspec.yaml` → cross-platform wrapper, not supported — stop and tell the user. No `.xcodeproj` / `.xcworkspace`, or no iOS **app** target (e.g. a library-only `Package.swift`) → stop; this skill needs a real iOS app project with an app target.
- Find `.xcodeproj` / `.xcworkspace` and list iOS app targets.
- **One** iOS app target → use it; confirm to the user ("I'll use target **AppName**").
- **Multiple** iOS app targets → ask which one.
- Determine the dependency manager:
- `Podfile` present → CocoaPods.
- `Package.swift` or SPM references in `.xcodeproj` → SPM.
- Neither → default to SPM and tell the user.
- Check deployment target (`IPHONEOS_DEPLOYMENT_TARGET`) — must be ≥ iOS 13.0. If it's lower, surface the current value and tell the user it must be raised to 13.0 to proceed; ask permission to raise it (a project mutation — fold this into the install approval). Raise it where the client declares it, and make sure the value actually reaches the app target. If the user refuses, stop — the SDK won't build below 13.0.
Summarise findings for the user before proceeding.
## Next — install the framework — pick CocoaPods or SPM
- Both `Podfile` **and** SPM present → don't guess; ask the user which to use, then follow one of the next installation links.
- `Podfile` only → [`2a-install-cocoapods.md`](2a-install-cocoapods.md)
- SPM, or no dependency manager yet (default to SPM) → [`2b-install-spm.md`](2b-install-spm.md)
ios/stages/2a-install-cocoapods.md
# Step 2A — Install via CocoaPods
> **You're here if:** CocoaPods is the chosen dependency manager (per Step 1).
> **Prereqs:** Step 1 done — you know the app target.
The project already has a `Podfile` (we don't create one — no Podfile means SPM). Merge the Sumsub pieces into it; the full example is in [`../examples/Podfile.snippet`](../examples/Podfile.snippet):
- **Sources** (top of the Podfile, outside any target): `IdensicMobileSDK` lives in Sumsub's spec repo, so add `source 'https://github.com/sumsub/Specs.git'`. If the Podfile declares no `source` yet, also add the default `source 'https://cdn.cocoapods.org/'` — once any custom source is present, the default must be explicit.
- **Pods** (inside the existing app target's `target '…' do … end` block): `pod 'IdensicMobileSDK'`, plus a line per module confirmed in intake (Q3) — e.g. `pod 'IdensicMobileSDK/MRTDReader'`. None confirmed → just the base.
- **`use_frameworks!`**: IdensicMobileSDK is a dynamic Swift framework, so the app target needs dynamic linking. Check whether `use_frameworks!` is already declared (at the Podfile root or inside the app target's block). If it's missing, add it — prefer inside the app target's `do … end` block to scope it to that target. If the project deliberately uses static linking, flag the conflict and ask rather than forcing it.
- **Deployment target**: the SDK needs iOS ≥ 13. The effective minimum comes from both the Podfile's `platform :ios` line and the project's `IPHONEOS_DEPLOYMENT_TARGET` — reconcile them: both must be `13.0`+ and the value must actually reach the app target, otherwise `pod install` won't resolve. Raise it where the client declared it (the `platform` line, the project, or the target's build settings).
In the snippet `YourApp` is a placeholder — use the project's real app target. Apply in **one edit**; tell the user exactly what you'll add and why, ask once, and edit only after explicit approval.
After editing, ask: "Should I run `pod install` now, or would you prefer to run it yourself?" Run it **only with explicit permission**.
## Next
- Add permissions & capabilities → [`3-permissions.md`](3-permissions.md) (it also covers the Info.plist / entitlement keys any modules you added need)
ios/stages/2b-install-spm.md
# Step 2B — Install via Swift Package Manager
> **You're here if:** SPM is the chosen dependency manager (per Step 1; the default).
> **Prereqs:** Step 1 done — you know the app target.
The base package is `https://github.com/sumsub/IdensicMobileSDK-iOS.git`, product `IdensicMobileSDK`. On top of it, add each optional module the user confirmed in intake (Q3) — if they confirmed none, just the base. Each module is its **own** SPM package (repo + product), pinned to the same version as the base; all four are listed in [`../examples/Package.swift.snippet`](../examples/Package.swift.snippet) (commented out).
Tell the user exactly what you'll add and why, ask once, and edit only after explicit approval.
Pick the path by whether the project has a `Package.swift`:
1. **No `Package.swift`** — an `.xcodeproj` / `.xcworkspace` app (the usual case). Add the package programmatically by editing `project.pbxproj` — see [`../references/spm-pbxproj.md`](../references/spm-pbxproj.md).
2. **Has a `Package.swift`** — an SPM package / SPM-defined target. Edit it: add the `.package` + `.product` entries (see [`../examples/Package.swift.snippet`](../examples/Package.swift.snippet)).
Both file-editing paths need the latest tag for the version (`from:`, or the pbxproj version rule) — resolve it with:
```bash
git ls-remote --tags --refs https://github.com/sumsub/IdensicMobileSDK-iOS.git \
| awk -F/ '{print $NF}' | sort -V | tail -1
```
## Next
- Add permissions & capabilities → [`3-permissions.md`](3-permissions.md) (it also covers the Info.plist / entitlement keys any modules you added need)
ios/stages/3-permissions.md
# Step 3 — Permissions & capabilities
> **You're here if:** always
> **Prereqs:** the framework (and any confirmed modules) installed (Step 2).
This step declares everything the OS needs: the base permission keys, **plus** the extra Info.plist / entitlement keys for any optional modules added in Step 2.
> **Gotcha — "Info.plist" may be build settings.** Modern Xcode targets often have **no `Info.plist` file**; its keys live in the target's build settings as `INFOPLIST_KEY_…` (e.g. `INFOPLIST_KEY_NSCameraUsageDescription`). Check `GENERATE_INFOPLIST_FILE` / `INFOPLIST_FILE` first. The **string** usage-description keys map directly (`INFOPLIST_KEY_NSCameraUsageDescription`, etc.). The **array** keys — `select-identifiers` and `UIBackgroundModes` — have no clean `INFOPLIST_KEY_` form: if the target has no Info.plist file, create one and point `INFOPLIST_FILE` at it, then add the arrays there. Leave `GENERATE_INFOPLIST_FILE = YES` — Xcode merges the `INFOPLIST_KEY_` strings into that file. (The `.entitlements` key is always its own file.)
## Permissions
The base SDK needs these four keys:
- `NSCameraUsageDescription`
- `NSMicrophoneUsageDescription`
- `NSPhotoLibraryUsageDescription`
- `NSLocationWhenInUseUsageDescription`
Only if **MRTDReader** or **EID** are confirmed in intake:
- `NFCReaderUsageDescription`
All the string descriptions for these keys are in [`../examples/Info.plist.snippet`](../examples/Info.plist.snippet).
Check the project (Info.plist, build settings, or `.entitlements`) first, then ask **once** before writing:
- **Missing keys** — add them in one batch (use the snippet's strings, or the app's own wording).
- **Keys already set** — never silently overwrite; show the current value and ask whether to keep or update.
> **Gotcha — localized usage strings.** iOS localizes usage-description strings via
> `<lang>.lproj/InfoPlist.strings` (entries keyed by the same key, e.g.
> `"NSCameraUsageDescription" = "We use the camera to capture your documents.";`). When an app does
> this, the value in the base `Info.plist` is just the development-language fallback and is often
> left as the **key name itself** or a placeholder — that is **not** a rejectable stub. So before
> flagging an existing usage-description value as a stub or overwriting it, grep for that key in
> `**/*.lproj/InfoPlist.strings`. If a localized string exists, the permission is properly described
> — leave it alone (don't raise an App Store warning, don't overwrite the plist value). When the app
> localizes Info.plist (any `InfoPlist.strings` present) and you're **adding** new keys, put the
> human-readable description in the localized `InfoPlist.strings` (at least `Base.lproj`) to match
> the app's pattern, not only a literal string in `Info.plist`.
## NFC configuration
Only if **MRTDReader** or **EID** are confirmed in intake:
- Add in **Info.plist** the `com.apple.developer.nfc.readersession.iso7816.select-identifiers` key. Value is an array with per-module AIDs (see [`../examples/Info.plist.snippet`](../examples/Info.plist.snippet)). Add the AIDs for each confirmed module; if both, union them and keep the shared AID **only once**.
- Add to **.entitlements**: `com.apple.developer.nfc.readersession.formats` = `["TAG"]` (the "Near Field Communication Tag Reading" capability). If the target has no `.entitlements` file, create one and set `CODE_SIGN_ENTITLEMENTS` to its path. Under automatic signing Xcode registers it on the App ID for you; manual step only under manual signing or a provisioning failure.
## UIBackgroundModes
Only if **VideoIdent** is confirmed in intake:
- Add in **Info.plist** the `audio` string to the `UIBackgroundModes` array.
## Next
- Create the integration code → [`4-integration-code.md`](4-integration-code.md)
ios/stages/4-integration-code.md
# Step 4 — Integration code
> **You're here if:** always — this is the core of the integration.
> **Prereqs:** framework installed (Step 2), Info.plist permissions added (Step 3).
Create a new Swift file encapsulating the SDK lifecycle, named `SumsubVerification.swift` (template: [`../examples/SumsubVerification.swift`](../examples/SumsubVerification.swift)). The template wires the SDK handlers, including the **required** `tokenExpirationHandler` — without it the flow hangs when the token expires mid-session.
`SumsubVerification.start()` is the single launch path for **both** UIKit and SwiftUI — the SDK presents itself over the key window's root VC and dismisses itself. (Do **not** build a `mainVC` + `.fullScreenCover` bridge — see [`6b-launch-swiftui.md`](6b-launch-swiftui.md) for why it breaks the SDK lifecycle.)
**SwiftUI launch point → optional thin helper.** If the launch point from intake (Q1) is a SwiftUI view, you *may* also create `SumsubSwiftUI.swift` — a one-line `SumsubPresenter` `ObservableObject` that forwards to `SumsubVerification.start()` (template: [`../examples/SumsubSwiftUI.swift`](../examples/SumsubSwiftUI.swift)). It's only a small seam for exposing verification state to the UI later; the view can just hold `SumsubVerification` directly. If you do create it, add it to the same change-set so Step 5 wires **both** files into the target. (UIKit needs only `SumsubVerification.swift`.)
**Placement isn't a separate question.** Choose a spot that fits the project's structure (from the Step 1 scan — near similar service/manager files, or a sensible group), then **name it in the change-set approval before creating it**:
> "I'll create `SumsubVerification.swift` in `<group / folder>` — apply?"
The user redirects there if they want it elsewhere. Note that in Xcode the **group** and the filesystem **folder** can differ (classic projects) or match (synchronized groups, Xcode 16+) — place it sensibly in both. (Getting the file into the target — synchronized groups vs `project.pbxproj` — is [`5-wire-target.md`](5-wire-target.md).)
Do **not** edit any existing app code except the one call site the user named in intake (Q1). Never touch unrelated files.
> **Swift 6 / strict concurrency.** If the project builds in Swift 6 mode (check `SWIFT_VERSION` / `SWIFT_STRICT_CONCURRENCY` in build settings), the launch class needs `@MainActor` plus explicit main-actor hops inside SDK callbacks — see [`../references/swift6-concurrency.md`](../references/swift6-concurrency.md).
## Token stub (no backend yet)
If the user has no backend token source yet, create the stub and explain clearly:
> "I'll add a `fetchAccessToken` placeholder that currently returns `nil`. The SDK will not launch until you replace this with a real network call to your backend. Even a hardcoded sandbox token string works for initial testing — the SDK will launch and you can see the flow."
## Next
- Register the new file(s) in the target → [`5-wire-target.md`](5-wire-target.md)
ios/stages/5-wire-target.md
# Step 5 — Wire the new file into the target
> **You're here if:** you just created a `.swift` file (the integration file).
> **Prereqs:** the file exists on disk.
A freshly-written `.swift` file on disk is **not** compiled until it is a member of the app target. Don't leave this to the user — handle it, then verify:
- **Synchronized groups (Xcode 16+):** if the target uses `PBXFileSystemSynchronizedRootGroup` (grep `project.pbxproj` for it), any file placed inside the target's folder is included automatically — nothing to edit. Just write the file in the right directory and say so.
- **Classic pbxproj:** the file must be registered explicitly — see [`../references/pbxproj-editing.md`](../references/pbxproj-editing.md).
This applies to **every** file you create — `SumsubVerification.swift`, the optional SwiftUI helper `SumsubSwiftUI.swift` (if you created one in Step 4), and later `SumsubTheme.swift` from the theming skill.
## Next
- Wire the launch point for your UI framework:
- UIKit → [`6a-launch-uikit.md`](6a-launch-uikit.md)
- SwiftUI → [`6b-launch-swiftui.md`](6b-launch-swiftui.md)
ios/stages/6a-launch-uikit.md
# Step 6A — Launch point (UIKit)
> **You're here if:** the host app launch screen is a UIKit `UIViewController`.
> **Prereqs:** the integration file is created and in the target.
Add the call to the ViewController the user named in intake. Before writing, show the user the exact lines you intend to add:
```swift
// In the ViewController the user specified — keep a strong property, not a
// throwaway local: the token fetch is async, and a local would deallocate first.
private let sumsub = SumsubVerification()
// …then, on the trigger (e.g. button tap):
sumsub.start(from: self)
```
## Next
- Core flow is done — return to **Handoff** in [`../../SKILL.md`](../../SKILL.md).
ios/stages/6b-launch-swiftui.md
# Step 6B — Launch point (SwiftUI)
> **You're here if:** the host app launch screen is a SwiftUI view.
> **Prereqs:** the integration file (`SumsubVerification.swift`) is created (Step 4) and in the target (Step 5). The thin `SumsubSwiftUI.swift` helper is optional — see below.
**Let the SDK present itself — the same path as UIKit.** From a SwiftUI view, just trigger `SumsubVerification.start()`. The SDK presents over the key window's root view controller (a `UIHostingController` in a SwiftUI app — this works fine) and dismisses itself on finish/close. Hold the launcher in a `@StateObject` (or `@State`) so it survives the async token fetch.
```swift
// Held so it isn't deallocated during the async token fetch.
@StateObject private var sumsub = SumsubPresenter()
Button("Verify") { sumsub.launch() }
```
`SumsubPresenter` (in `SumsubSwiftUI.swift`) is a one-line forwarder to `SumsubVerification.start()`. You can skip it entirely and hold `SumsubVerification` directly:
```swift
@State private var sumsub = SumsubVerification()
Button("Verify") { sumsub.start() }
```
> **Do NOT bridge `sdk.mainVC` into a `.fullScreenCover` / `.sheet`.** It looks idiomatic, but it breaks the SDK's lifecycle. The SDK's `mainVC` (a `UINavigationController`) **strong-retains the SDK**, and the SDK fires `onDidDismiss` from that controller's `dealloc`. If SwiftUI (or your own `sdkVC` property) strong-holds `mainVC`, the controller doesn't dealloc on time, so the dismiss callback fires late — on the *next* launch — stomping the new presentation's binding ("opens every other time") and crashing on the re-entrant release (`sdkVC = nil`). Separately, the SDK dismisses its own `mainVC`, so SwiftUI and the SDK fight over who owns the presentation. Letting the SDK present itself sidesteps all of this: one owner, clean teardown, reliable re-open. (Sumsub's own SwiftUI sample apps use the `.fullScreenCover` bridge but are one-shot demos that never re-open or reset the binding — don't copy them.)
> **Swift 6 / strict concurrency.** If the project builds in Swift 6 mode, mark the launch class `@MainActor` and hop to the main actor inside SDK callbacks. See [`../references/swift6-concurrency.md`](../references/swift6-concurrency.md).
## Next
- Core flow is done — return to **Handoff** in [`../../SKILL.md`](../../SKILL.md).
references/pbxproj-editing.md
# Registering a source file in a classic `project.pbxproj`
When the target does **not** use synchronized groups (no
`PBXFileSystemSynchronizedRootGroup` in `project.pbxproj`), a new `.swift` file must
be registered explicitly. Prefer the Ruby `xcodeproj` gem if available — it generates valid UUIDs; hand-edit only as fallback. To hand-edit, add the four entries by mirroring an existing sibling file:
1. a `PBXBuildFile`,
2. a `PBXFileReference`,
3. a child in the group, and
4. an entry in the target's `Sources` build phase.
Then validate with `plutil -lint project.pbxproj`. Do this yourself — do not ask the
user to drag the file in via Xcode.
> This is the canonical place for source-file registration. The theming skill
> (`sumsub-theme-msdk`) creates `SumsubTheme.swift` and wires it in the same way.
references/spm-pbxproj.md
# Adding an SPM package to an Xcode project's `project.pbxproj`
When the project is an Xcode project **without** a `Package.swift`, the SPM package
reference lives inside `project.pbxproj`. There is no first-party Apple CLI to add it,
but the file is editable.
- **Preferred (automatable):** edit `*.xcodeproj/project.pbxproj` directly to add an
`XCRemoteSwiftPackageReference` (the repo URL + version rule), an
`XCSwiftPackageProductDependency` for `IdensicMobileSDK`, and reference that product
in the app target's `Frameworks` build phase + `packageProductDependencies`. Mirror
an existing package entry if the project already has one. After editing, Xcode
resolves and writes `Package.resolved` on next open. If the Ruby `xcodeproj` gem is
available, prefer it over hand-editing — it generates valid UUIDs for you. **Always
show the diff and ask before editing the pbxproj**, then have the user open Xcode to
let it resolve.
- **Fallback (manual):** if pbxproj editing is risky for this project (unusual layout,
no template entry to mirror), instruct the user to add it via *File → Add Package
Dependencies* in Xcode, paste the URL, and pick the `IdensicMobileSDK` product.
references/swift6-concurrency.md
# Swift 6 / strict concurrency
> Applies when the project builds in **Swift 6 mode** (or has "default actor isolation = MainActor"). Detect from `SWIFT_VERSION` / `SWIFT_STRICT_CONCURRENCY` in build settings before assuming.
The SDK's callbacks are **nonisolated**, so the compiler will reject capturing main-actor state in them — e.g. *"Task-isolated 'onDismiss' is captured by a main actor-isolated closure"*.
- Mark the launch class `@MainActor`.
- Inside SDK callbacks, hop explicitly before touching app/UI state: `sdk.onDidDismiss { _ in Task { @MainActor in onDismiss?() } }` (or `DispatchQueue.main.async { … }`, as in the SwiftUI presenter [`../examples/SumsubSwiftUI.swift`](../examples/SumsubSwiftUI.swift)).
Applies when writing the integration code — the launch class and its SDK callbacks ([`4-integration-code.md`](../stages/4-integration-code.md)) — and to the SwiftUI presenter's binding hop ([`6b-launch-swiftui.md`](../stages/6b-launch-swiftui.md)).
SKILL.md
---
name: sumsub-integrate-msdk
description: Integrate the Sumsub Mobile SDK (IdensicMobileSDK / SNSMobileSDK) into a native iOS or Android app — even when the user doesn't name the SDK (e.g. "add Sumsub/KYC to my iPhone app", "add KYC to my Android app", "show the verification screen", "launch Sumsub from a view controller / activity"). Detects the platform from the project (iOS Xcode/Swift vs Android Gradle/Kotlin) and follows the matching track. Covers install, permissions, init, token refresh, and presenting / launching the flow. SKIP for web (`sumsub-integrate-websdk`), backend / API-only token signing (`sumsub-api-generic`), theming (`sumsub-theme-msdk`), and React Native / Flutter (not supported — native iOS & Android only).
allowed-tools: Read, Write, Edit, Bash, Grep, Glob
---
# Sumsub — Mobile SDK integration (iOS & Android)
Embed Sumsub KYC into an existing native app via the **`IdensicMobileSDK`** framework
(main class **`SNSMobileSDK`**), from dependency install to presenting / launching the
verification flow — on **iOS** (Swift) or **Android** (Kotlin).
This is the **trunk**: read the always-on sections below (intake, asking-vs-doing),
detect the platform, then use the [decision table](#how-to-navigate-this-skill) to open
**only** the branch files that match the platform, the project, and the user's answers.
## Intake — ask these three questions first, in one message
Before touching any project file, ask all three together:
1. **Launch point.** "From which screen should the Sumsub flow open? Please share the
class name or describe the screen." — *iOS:* a ViewController or SwiftUI view;
*Android:* an Activity, Fragment, or Composable.
2. **Access token source.** "Does your app already have a way to fetch a Sumsub
access token from your backend? (If yes, share the function name, endpoint URL,
or service class. If not, I'll add a placeholder you can fill in when your
backend is ready.)"
3. **Optional modules.** "The base SDK covers standard verification flows and is **always installed**. On top of it, do
you need any of these
add-ons?
- **NFC** (passport / eMRTD chip reading) — *iOS:* `MRTDReader`; *Android:* the `nfc` module – read the chip on
biometric passports
- **VideoIdent** — live video call with a moderator
- **EID** — German eID card reading
- **Fisherman / Device Intelligence** — fraud signals *(iOS: optional
module; **Android: bundled in the base SDK since 1.43.0** — nothing to add)*
Skip them if you're not sure — they can be added later."
Modules are **purely additive**. In a multi-select, list **only the modules** —
an empty selection already means "base only"; don't add "base only" as a
co-selectable peer.
Do **not** ask about: the App Token or the secret key — those are server concerns
that never touch the app.
## Asking vs doing — keep approvals meaningful
- **Read-only is free** — scan / grep / read `Info.plist`, `AndroidManifest.xml` & build files without asking.
- **Batch mutations into one approval**, not one per line ("I'll add these 3 Info.plist
keys [list] + this call site [diff] — apply?").
- **Ask explicitly only for:** editing existing app code, project-mutating commands
(`pod install`, a Gradle sync), anything irreversible, and decisions you couldn't settle in intake.
## Detect the platform (do this before opening any branch file)
Decide whether this is an **iOS** or **Android** project — the install steps, the
permission model, and the generated language all differ. Read-only.
```bash
# iOS markers
find . -maxdepth 3 \( -name '*.xcodeproj' -o -name '*.xcworkspace' -o -name 'Podfile' -o -name 'Package.swift' \) 2>/dev/null
# Android markers
find . -maxdepth 3 \( -name 'build.gradle' -o -name 'build.gradle.kts' -o -name 'settings.gradle*' -o -name 'AndroidManifest.xml' \) 2>/dev/null
```
- **iOS only** (`.xcodeproj` / `Package.swift` / `Podfile`, no Gradle) → **iOS track**.
- **Android only** (`build.gradle[.kts]` / `AndroidManifest.xml`, no Xcode) → **Android track**.
- **Both present** (a monorepo, or a React-Native / Flutter shell with native folders) →
report both and **ask which platform to integrate.** One platform per run. (React
Native / Flutter wrappers themselves are out of scope — this skill targets the native
SDKs; the Step-1 scan bails on a detected wrapper.)
- **Neither** → ask the user to point at the app's source root.
Confirm the detected platform if there was any ambiguity, then follow the matching track.
## How to navigate this skill
After intake, open ONLY the rows that match the **detected platform**, the project (from
Step 1), and the user's intake answers. Each branch file is self-contained and ends with
a **Next** pointer. A letter (2A/2B, 6A/6B) marks a branch — pick one; plain numbers run
in sequence.
### iOS track
| Step | Condition | Read |
|-------------------------------|----------------------------------------|----------------------------------------------------------------------------|
| 1. Scan | always (first, after intake) | [`ios/stages/1-scan.md`](ios/stages/1-scan.md) |
| 2A. Install — CocoaPods | CocoaPods chosen (per Step 1) | [`ios/stages/2a-install-cocoapods.md`](ios/stages/2a-install-cocoapods.md) |
| 2B. Install — SPM | SPM chosen (per Step 1; the default) | [`ios/stages/2b-install-spm.md`](ios/stages/2b-install-spm.md) |
| 3. Permissions & capabilities | always | [`ios/stages/3-permissions.md`](ios/stages/3-permissions.md) |
| 4. Integration file | always | [`ios/stages/4-integration-code.md`](ios/stages/4-integration-code.md) |
| 5. Wire into target | always (right after creating the file) | [`ios/stages/5-wire-target.md`](ios/stages/5-wire-target.md) |
| 6A. Launch — UIKit | host is a UIKit ViewController | [`ios/stages/6a-launch-uikit.md`](ios/stages/6a-launch-uikit.md) |
| 6B. Launch — SwiftUI | host is a SwiftUI view | [`ios/stages/6b-launch-swiftui.md`](ios/stages/6b-launch-swiftui.md) |
### Android track
| Step | Condition | Read |
|---------------------------|----------------------------------------------------------|----------------------------------------------------------------------------------------|
| 1. Scan | always (first, after intake) | [`android/stages/1-scan.md`](android/stages/1-scan.md) |
| 2. Install — Dependencies | always | [`android/stages/2-install-dependencies.md`](android/stages/2-install-dependencies.md) |
| 3. Integration file | always (Gradle auto-compiles — no wire-into-target step) | [`android/stages/3-integration-code.md`](android/stages/3-integration-code.md) |
| 4. Launch | always | [`android/stages/4-launch.md`](android/stages/4-launch.md) |
> **Fallback (last resort only).** This skill and its branch files are the primary
> source — don't reach for external docs by default. Only if you hit a genuine blocker
> they don't resolve, consult the Sumsub docs:
> [iOS](https://docs.sumsub.com/docs/get-started-ios) ·
> [Android](https://docs.sumsub.com/docs/get-started-android).
When the tree is walked, return here for **Guardrails** and **Handoff**.
## Guardrails — what this skill must never do
- **Detect the platform first** — never emit Swift into an Android project or Kotlin into an iOS one; if both are
present, ask which to integrate.
- **Never edit existing app code** beyond the single targeted call site the user approved in intake.
- **(iOS) Let the SDK present itself — in SwiftUI too.** Always launch via `SumsubVerification.start()` (
`sdk.present()` / `sdk.present(from:)`). **Never** strong-hold the SDK or its `mainVC` across the flow, and **never**
bridge `sdk.mainVC` into a SwiftUI `.fullScreenCover` / `.sheet`. `mainVC` strong-retains the SDK and the SDK fires
`onDidDismiss` from that controller's `dealloc`; holding it yourself delays teardown and causes "opens every other
time" + a crash on re-open. The SDK owns presentation **and** dismissal — one owner, clean re-open.
- **(Android) Never add camera/mic permissions or runtime prompts** — the SDK declares and requests them itself; adding
your own can double-prompt or conflict.
- **(Android) The token-refresh handler is synchronous** — `onTokenExpired()` must return the token on a background
thread; bridge the suspend token call with `runBlocking`, not the iOS async/callback pattern.
- **Never run a project-mutating command** (`pod install`, a Gradle sync/build) without explicit user permission in the
current message.
- **Never ask for or reference the App Token or secret key** — tokens are minted server-side.
- **Never store or reference the App Token or secret key** in any app file.
- **Never silently overwrite** an existing Info.plist / AndroidManifest value — always show the current value and ask
first.
- **Never apply theme changes** in this skill — styling belongs in `sumsub-theme-msdk`.
- **Never gate access on in-app callbacks** — the authoritative verdict comes from the backend (webhook + applicant
GET), not the SDK callbacks.
## Handoff
After all changes are complete, summarise clearly:
1. **Files created / modified** — list each with a one-line description.
2. **Stub that needs filling in** — if the token fetch is a placeholder, say so explicitly and describe what the user
must implement. (On Android it's the single `SumsubTokenProvider.fetchAccessToken` suspend function — the launcher
reuses it for mid-session refresh.)
3. **Build step** — iOS: "Run `pod install`, then open the `.xcworkspace` and build" or "Build and run — SPM packages
resolve automatically"; Android: "Sync Gradle, then build & run".
4. **How to test** — explain: get a sandbox access token from your backend (or temporarily hardcode one), trigger
verification from the launch point (iOS: `SumsubVerification.start(…)`; Android: the ViewModel action whose effect
makes the screen call `SumsubLauncher.present(…)`), and verify the Sumsub flow appears. (NFC modules need a
physical device — not the iOS simulator / Android emulator.)
5. **Source of truth** — remind the user: the final verification result comes from the backend webhook + applicant read,
not from the SDK callbacks.
6. **Next steps** — point to `sumsub-theme-msdk` for styling (iOS & Android).
stages/1-scan.md
# Step 1 — Scan the project (read-only)
> **You're here if:** intake is done (the three questions answered).
> **Prereqs:** none — this is read-only; no approval needed.
After intake, inspect without editing anything:
- **Bail early if not native iOS.** `package.json` with `react-native`, or a `pubspec.yaml` → cross-platform wrapper, not supported — stop and tell the user. No `.xcodeproj` / `.xcworkspace`, or no iOS **app** target (e.g. a library-only `Package.swift`) → stop; this skill needs a real iOS app project with an app target.
- Find `.xcodeproj` / `.xcworkspace` and list iOS app targets.
- **One** iOS app target → use it; confirm to the user ("I'll use target **AppName**").
- **Multiple** iOS app targets → ask which one.
- Determine the dependency manager:
- `Podfile` present → CocoaPods.
- `Package.swift` or SPM references in `.xcodeproj` → SPM.
- Neither → default to SPM and tell the user.
- Check deployment target (`IPHONEOS_DEPLOYMENT_TARGET`) — must be ≥ iOS 13.0. If it's lower, surface the current value and tell the user it must be raised to 13.0 to proceed; ask permission to raise it (a project mutation — fold this into the install approval). Raise it where the client declares it, and make sure the value actually reaches the app target. If the user refuses, stop — the SDK won't build below 13.0.
Summarise findings for the user before proceeding.
## Next — install the framework — pick CocoaPods or SPM
- Both `Podfile` **and** SPM present → don't guess; ask the user which to use, then follow one of the next installation links.
- `Podfile` only → [`2a-install-cocoapods.md`](2a-install-cocoapods.md)
- SPM, or no dependency manager yet (default to SPM) → [`2b-install-spm.md`](2b-install-spm.md)
stages/2a-install-cocoapods.md
# Step 2A — Install via CocoaPods
> **You're here if:** CocoaPods is the chosen dependency manager (per Step 1).
> **Prereqs:** Step 1 done — you know the app target.
The project already has a `Podfile` (we don't create one — no Podfile means SPM). Merge the Sumsub pieces into it; the full example is in [`../examples/Podfile.snippet`](../examples/Podfile.snippet):
- **Sources** (top of the Podfile, outside any target): `IdensicMobileSDK` lives in Sumsub's spec repo, so add `source 'https://github.com/sumsub/Specs.git'`. If the Podfile declares no `source` yet, also add the default `source 'https://cdn.cocoapods.org/'` — once any custom source is present, the default must be explicit.
- **Pods** (inside the existing app target's `target '…' do … end` block): `pod 'IdensicMobileSDK'`, plus a line per module confirmed in intake (Q3) — e.g. `pod 'IdensicMobileSDK/MRTDReader'`. None confirmed → just the base.
- **`use_frameworks!`**: IdensicMobileSDK is a dynamic Swift framework, so the app target needs dynamic linking. Check whether `use_frameworks!` is already declared (at the Podfile root or inside the app target's block). If it's missing, add it — prefer inside the app target's `do … end` block to scope it to that target. If the project deliberately uses static linking, flag the conflict and ask rather than forcing it.
- **Deployment target**: the SDK needs iOS ≥ 13. The effective minimum comes from both the Podfile's `platform :ios` line and the project's `IPHONEOS_DEPLOYMENT_TARGET` — reconcile them: both must be `13.0`+ and the value must actually reach the app target, otherwise `pod install` won't resolve. Raise it where the client declared it (the `platform` line, the project, or the target's build settings).
In the snippet `YourApp` is a placeholder — use the project's real app target. Apply in **one edit**; tell the user exactly what you'll add and why, ask once, and edit only after explicit approval.
After editing, ask: "Should I run `pod install` now, or would you prefer to run it yourself?" Run it **only with explicit permission**.
## Next
- Add permissions & capabilities → [`3-permissions.md`](3-permissions.md) (it also covers the Info.plist / entitlement keys any modules you added need)
stages/2b-install-spm.md
# Step 2B — Install via Swift Package Manager
> **You're here if:** SPM is the chosen dependency manager (per Step 1; the default).
> **Prereqs:** Step 1 done — you know the app target.
The base package is `https://github.com/sumsub/IdensicMobileSDK-iOS.git`, product `IdensicMobileSDK`. On top of it, add each optional module the user confirmed in intake (Q3) — if they confirmed none, just the base. Each module is its **own** SPM package (repo + product), pinned to the same version as the base; all four are listed in [`../examples/Package.swift.snippet`](../examples/Package.swift.snippet) (commented out).
Tell the user exactly what you'll add and why, ask once, and edit only after explicit approval.
Pick the path by whether the project has a `Package.swift`:
1. **No `Package.swift`** — an `.xcodeproj` / `.xcworkspace` app (the usual case). Add the package programmatically by editing `project.pbxproj` — see [`../references/spm-pbxproj.md`](../references/spm-pbxproj.md).
2. **Has a `Package.swift`** — an SPM package / SPM-defined target. Edit it: add the `.package` + `.product` entries (see [`../examples/Package.swift.snippet`](../examples/Package.swift.snippet)).
Both file-editing paths need the latest tag for the version (`from:`, or the pbxproj version rule) — resolve it with:
```bash
git ls-remote --tags --refs https://github.com/sumsub/IdensicMobileSDK-iOS.git \
| awk -F/ '{print $NF}' | sort -V | tail -1
```
## Next
- Add permissions & capabilities → [`3-permissions.md`](3-permissions.md) (it also covers the Info.plist / entitlement keys any modules you added need)
stages/3-permissions.md
# Step 3 — Permissions & capabilities
> **You're here if:** always
> **Prereqs:** the framework (and any confirmed modules) installed (Step 2).
This step declares everything the OS needs: the base permission keys, **plus** the extra Info.plist / entitlement keys for any optional modules added in Step 2.
> **Gotcha — "Info.plist" may be build settings.** Modern Xcode targets often have **no `Info.plist` file**; its keys live in the target's build settings as `INFOPLIST_KEY_…` (e.g. `INFOPLIST_KEY_NSCameraUsageDescription`). Check `GENERATE_INFOPLIST_FILE` / `INFOPLIST_FILE` first. The **string** usage-description keys map directly (`INFOPLIST_KEY_NSCameraUsageDescription`, etc.). The **array** keys — `select-identifiers` and `UIBackgroundModes` — have no clean `INFOPLIST_KEY_` form: if the target has no Info.plist file, create one and point `INFOPLIST_FILE` at it, then add the arrays there. Leave `GENERATE_INFOPLIST_FILE = YES` — Xcode merges the `INFOPLIST_KEY_` strings into that file. (The `.entitlements` key is always its own file.)
## Permissions
The base SDK needs these four keys:
- `NSCameraUsageDescription`
- `NSMicrophoneUsageDescription`
- `NSPhotoLibraryUsageDescription`
- `NSLocationWhenInUseUsageDescription`
Only if **MRTDReader** or **EID** are confirmed in intake:
- `NFCReaderUsageDescription`
All the string descriptions for these keys are in [`../examples/Info.plist.snippet`](../examples/Info.plist.snippet).
Check the project (Info.plist, build settings, or `.entitlements`) first, then ask **once** before writing:
- **Missing keys** — add them in one batch (use the snippet's strings, or the app's own wording).
- **Keys already set** — never silently overwrite; show the current value and ask whether to keep or update.
> **Gotcha — localized usage strings.** iOS localizes usage-description strings via
> `<lang>.lproj/InfoPlist.strings` (entries keyed by the same key, e.g.
> `"NSCameraUsageDescription" = "We use the camera to capture your documents.";`). When an app does
> this, the value in the base `Info.plist` is just the development-language fallback and is often
> left as the **key name itself** or a placeholder — that is **not** a rejectable stub. So before
> flagging an existing usage-description value as a stub or overwriting it, grep for that key in
> `**/*.lproj/InfoPlist.strings`. If a localized string exists, the permission is properly described
> — leave it alone (don't raise an App Store warning, don't overwrite the plist value). When the app
> localizes Info.plist (any `InfoPlist.strings` present) and you're **adding** new keys, put the
> human-readable description in the localized `InfoPlist.strings` (at least `Base.lproj`) to match
> the app's pattern, not only a literal string in `Info.plist`.
## NFC configuration
Only if **MRTDReader** or **EID** are confirmed in intake:
- Add in **Info.plist** the `com.apple.developer.nfc.readersession.iso7816.select-identifiers` key. Value is an array with per-module AIDs (see [`../examples/Info.plist.snippet`](../examples/Info.plist.snippet)). Add the AIDs for each confirmed module; if both, union them and keep the shared AID **only once**.
- Add to **.entitlements**: `com.apple.developer.nfc.readersession.formats` = `["TAG"]` (the "Near Field Communication Tag Reading" capability). If the target has no `.entitlements` file, create one and set `CODE_SIGN_ENTITLEMENTS` to its path. Under automatic signing Xcode registers it on the App ID for you; manual step only under manual signing or a provisioning failure.
## UIBackgroundModes
Only if **VideoIdent** is confirmed in intake:
- Add in **Info.plist** the `audio` string to the `UIBackgroundModes` array.
## Next
- Create the integration code → [`4-integration-code.md`](4-integration-code.md)
stages/4-integration-code.md
# Step 4 — Integration code
> **You're here if:** always — this is the core of the integration.
> **Prereqs:** framework installed (Step 2), Info.plist permissions added (Step 3).
Create a new Swift file encapsulating the SDK lifecycle, named `SumsubVerification.swift` (template: [`../examples/SumsubVerification.swift`](../examples/SumsubVerification.swift)). The template wires the SDK handlers, including the **required** `tokenExpirationHandler` — without it the flow hangs when the token expires mid-session.
`SumsubVerification.start()` is the single launch path for **both** UIKit and SwiftUI — the SDK presents itself over the key window's root VC and dismisses itself. (Do **not** build a `mainVC` + `.fullScreenCover` bridge — see [`6b-launch-swiftui.md`](6b-launch-swiftui.md) for why it breaks the SDK lifecycle.)
**SwiftUI launch point → optional thin helper.** If the launch point from intake (Q1) is a SwiftUI view, you *may* also create `SumsubSwiftUI.swift` — a one-line `SumsubPresenter` `ObservableObject` that forwards to `SumsubVerification.start()` (template: [`../examples/SumsubSwiftUI.swift`](../examples/SumsubSwiftUI.swift)). It's only a small seam for exposing verification state to the UI later; the view can just hold `SumsubVerification` directly. If you do create it, add it to the same change-set so Step 5 wires **both** files into the target. (UIKit needs only `SumsubVerification.swift`.)
**Placement isn't a separate question.** Choose a spot that fits the project's structure (from the Step 1 scan — near similar service/manager files, or a sensible group), then **name it in the change-set approval before creating it**:
> "I'll create `SumsubVerification.swift` in `<group / folder>` — apply?"
The user redirects there if they want it elsewhere. Note that in Xcode the **group** and the filesystem **folder** can differ (classic projects) or match (synchronized groups, Xcode 16+) — place it sensibly in both. (Getting the file into the target — synchronized groups vs `project.pbxproj` — is [`5-wire-target.md`](5-wire-target.md).)
Do **not** edit any existing app code except the one call site the user named in intake (Q1). Never touch unrelated files.
> **Swift 6 / strict concurrency.** If the project builds in Swift 6 mode (check `SWIFT_VERSION` / `SWIFT_STRICT_CONCURRENCY` in build settings), the launch class needs `@MainActor` plus explicit main-actor hops inside SDK callbacks — see [`../references/swift6-concurrency.md`](../references/swift6-concurrency.md).
## Token stub (no backend yet)
If the user has no backend token source yet, create the stub and explain clearly:
> "I'll add a `fetchAccessToken` placeholder that currently returns `nil`. The SDK will not launch until you replace this with a real network call to your backend. Even a hardcoded sandbox token string works for initial testing — the SDK will launch and you can see the flow."
## Next
- Register the new file(s) in the target → [`5-wire-target.md`](5-wire-target.md)
stages/5-wire-target.md
# Step 5 — Wire the new file into the target
> **You're here if:** you just created a `.swift` file (the integration file).
> **Prereqs:** the file exists on disk.
A freshly-written `.swift` file on disk is **not** compiled until it is a member of the app target. Don't leave this to the user — handle it, then verify:
- **Synchronized groups (Xcode 16+):** if the target uses `PBXFileSystemSynchronizedRootGroup` (grep `project.pbxproj` for it), any file placed inside the target's folder is included automatically — nothing to edit. Just write the file in the right directory and say so.
- **Classic pbxproj:** the file must be registered explicitly — see [`../references/pbxproj-editing.md`](../references/pbxproj-editing.md).
This applies to **every** file you create — `SumsubVerification.swift`, the optional SwiftUI helper `SumsubSwiftUI.swift` (if you created one in Step 4), and later `SumsubTheme.swift` from the theming skill.
## Next
- Wire the launch point for your UI framework:
- UIKit → [`6a-launch-uikit.md`](6a-launch-uikit.md)
- SwiftUI → [`6b-launch-swiftui.md`](6b-launch-swiftui.md)
stages/6a-launch-uikit.md
# Step 6A — Launch point (UIKit)
> **You're here if:** the host app launch screen is a UIKit `UIViewController`.
> **Prereqs:** the integration file is created and in the target.
Add the call to the ViewController the user named in intake. Before writing, show the user the exact lines you intend to add:
```swift
// In the ViewController the user specified — keep a strong property, not a
// throwaway local: the token fetch is async, and a local would deallocate first.
private let sumsub = SumsubVerification()
// …then, on the trigger (e.g. button tap):
sumsub.start(from: self)
```
## Next
- Core flow is done — return to **Handoff** in [`../SKILL.md`](../SKILL.md).
stages/6b-launch-swiftui.md
# Step 6B — Launch point (SwiftUI)
> **You're here if:** the host app launch screen is a SwiftUI view.
> **Prereqs:** the integration file (`SumsubVerification.swift`) is created (Step 4) and in the target (Step 5). The thin `SumsubSwiftUI.swift` helper is optional — see below.
**Let the SDK present itself — the same path as UIKit.** From a SwiftUI view, just trigger `SumsubVerification.start()`. The SDK presents over the key window's root view controller (a `UIHostingController` in a SwiftUI app — this works fine) and dismisses itself on finish/close. Hold the launcher in a `@StateObject` (or `@State`) so it survives the async token fetch.
```swift
// Held so it isn't deallocated during the async token fetch.
@StateObject private var sumsub = SumsubPresenter()
Button("Verify") { sumsub.launch() }
```
`SumsubPresenter` (in `SumsubSwiftUI.swift`) is a one-line forwarder to `SumsubVerification.start()`. You can skip it entirely and hold `SumsubVerification` directly:
```swift
@State private var sumsub = SumsubVerification()
Button("Verify") { sumsub.start() }
```
> **Do NOT bridge `sdk.mainVC` into a `.fullScreenCover` / `.sheet`.** It looks idiomatic, but it breaks the SDK's lifecycle. The SDK's `mainVC` (a `UINavigationController`) **strong-retains the SDK**, and the SDK fires `onDidDismiss` from that controller's `dealloc`. If SwiftUI (or your own `sdkVC` property) strong-holds `mainVC`, the controller doesn't dealloc on time, so the dismiss callback fires late — on the *next* launch — stomping the new presentation's binding ("opens every other time") and crashing on the re-entrant release (`sdkVC = nil`). Separately, the SDK dismisses its own `mainVC`, so SwiftUI and the SDK fight over who owns the presentation. Letting the SDK present itself sidesteps all of this: one owner, clean teardown, reliable re-open. (Sumsub's own SwiftUI sample apps use the `.fullScreenCover` bridge but are one-shot demos that never re-open or reset the binding — don't copy them.)
> **Swift 6 / strict concurrency.** If the project builds in Swift 6 mode, mark the launch class `@MainActor` and hop to the main actor inside SDK callbacks. See [`../references/swift6-concurrency.md`](../references/swift6-concurrency.md).
## Next
- Core flow is done — return to **Handoff** in [`../SKILL.md`](../SKILL.md).