iOS setup
Install the Folio SDK Swift package in an iOS app, start the SDK and run an inquiry.
Preview
This page describes a pre-release version of the SDK. Names, versions and APIs on this page can change before the release.
This page adds the Folio SDK to an iOS app. For how the runtime, the stores and the inquiry fit together, read Get started first.
Requirements
- iOS 15 or later.
- Xcode 15 or later. The package manifests use Swift tools version 5.9.
- Swift Package Manager. The SDK ships as a Swift package; the native core is a static library
inside a binary framework,
FolioSDK.xcframework, linked statically into your app.
Packages
The SDK is one Swift package, FolioSDK, with three library products:
| Product | Contents |
|---|---|
FolioSDK | The Swift interface of the SDK: the runtime class FolioSdk, the stores (SessionStore, AuthStore, VaultStore, InquiryStore, MrtdStore, LogsStore, FolioDocumentListStore, FolioDocumentStore), their actions and state types, InquiryEntry, InquiryLifecycle, InquiryError, the host protocols, the SwiftUI view FolioSDKProvider and the error type FolioSDKError. |
FolioInquiryUI | The ready-made inquiry UI in SwiftUI: the view FolioInquiry, InquiryPlatform, the SelfieCaptureProvider protocol and its default implementation DefaultSelfieCaptureProvider. The icon images of FolioSdk.iconAsset in FolioResourceAssets.bundle; see Static functions. |
FolioSDKBinary | FolioSDK.xcframework, the native SDK library. |
Add FolioSDK, FolioInquiryUI and FolioSDKBinary to your app target: FolioSDK holds the
Swift declarations and FolioSDKBinary the native library behind them. FolioInquiryUI depends
on FolioSDK and on Lottie (lottie-spm, from 4.6.1), which Swift Package Manager resolves for
you.
FolioSDK and FolioSDKBinary must come from the same release. On the first call into the SDK,
the Swift declarations compare their bindgen format version and metadata digest with the native
library and stop the app with fatalError when the two differ.
The package resolves the capture engine of the selfie step as a transitive package dependency at
an exact version and links FolioInquiryUI to it; see Selfie capture.
Install
Folio gives you access to the package repository, <ios-package-repository-url>.
In Xcode, choose Add Package Dependencies from the File menu, enter the repository URL, pick the
version and add the FolioSDK, FolioInquiryUI and FolioSDKBinary libraries to your app target.
In a Package.swift manifest, add the package and link the products. The package: argument of
.product is the package's identity, which Swift Package Manager takes from the last path
component of the repository URL, without .git, not from the name inside the package's manifest.
The repository Folio gives you access to is named FolioSDK-iOS, so the identity is
FolioSDK-iOS; if your URL ends differently, use its last path component:
dependencies: [
.package(url: "<ios-package-repository-url>", exact: "<version>"),
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "FolioSDK", package: "FolioSDK-iOS"),
.product(name: "FolioInquiryUI", package: "FolioSDK-iOS"),
.product(name: "FolioSDKBinary", package: "FolioSDK-iOS"),
]
),
]Then import the modules you use:
import FolioSDK
import FolioInquiryUIA release published again
Folio can publish a release again under the same version, with a new revision. Your
Package.resolved pins the revision you resolved, and Swift Package Manager also remembers the
revision it first resolved for each version of a package.
While Package.resolved still records the earlier revision and that revision is still reachable in
the repository, resolution checks nothing and does not fail:
xcodebuild -resolvePackageDependencies keeps the earlier binaries without any warning. To move to
the new revision, update the pin. In Xcode, choose Packages, then Update to Latest Package Versions
from the File menu. swift package update applies to a package manifest, not to an Xcode project;
for an .xcodeproj on the command line, remove the foliosdk-ios pin from Package.resolved, or
delete the file, and resolve again:
xcodebuild -resolvePackageDependencies -packageFingerprintPolicy warn -project <App>.xcodeproj -scheme <App>Once the pin is gone, Swift Package Manager compares the new revision with the one it remembers.
With -packageFingerprintPolicy warn passed to xcodebuild, or
--resolver-fingerprint-checking warn passed to swift package, it accepts the new revision and
prints a warning. Without that policy, as in Xcode, which has no setting for it, resolution fails
with an error that starts with Revision and says the revision
does not match previously recorded value.
To make the warning go away, or to resolve without the policy, delete the remembered revision:
remove the files whose names start with foliosdk-ios- from
~/Library/org.swift.swiftpm/security/fingerprints. The name starts with the package identity in
lowercase; if your repository URL ends differently, look for its last path component. Then resolve
the packages again: in Xcode, choose Packages, then Reset Package Caches from the File menu; on the
command line, run swift package reset and swift package resolve for a package manifest. For an
.xcodeproj, run this after deleting the files:
xcodebuild -resolvePackageDependencies -project <App>.xcodeproj -scheme <App>It needs no fingerprint policy: resolution passes and records the new revision.
Commit the updated Package.resolved. To confirm the build your app runs, read the revision of
the Stamped product of FolioSdk.sdkBuildInfo().
System frameworks
The FolioSDK target links these system frameworks. Swift Package Manager adds them to your app;
you do not add them by hand.
| Framework | Framework | Framework |
|---|---|---|
Foundation | Security | Network |
AuthenticationServices | DeviceCheck | LocalAuthentication |
UserNotifications | BackgroundTasks | CoreGraphics |
ImageIO | CoreServices | CoreNFC |
Configure your app
The inquiry UI uses these device features. Configure your app for each one the inquiries you run need, as Apple requires for the API involved:
| Feature | What the SDK does |
|---|---|
| Camera | The document capture step asks for camera access with AVCaptureDevice.requestAccess(for: .video). When access is denied or restricted, the step shows the camera-denied message from the inquiry instead of the camera. |
| Front camera | The capture engine of the selfie step runs the liveness check with the front camera. A selfie photo step opens UIImagePickerController with the front camera, or the photo library when the device has no camera. When the picked image cannot be read, the step reports it as an unreadable file. |
| NFC | The chip reading step opens an NFCTagReaderSession with ISO 14443 polling, accepts only ISO 7816 tags and selects the ICAO machine readable travel document application, AID A0000002471001. See NFC. |
| Photos, files | When the inquiry allows an upload, the UI opens PHPickerViewController or UIDocumentPickerViewController. |
On a device where NFCTagReaderSession.readingAvailable is false, the SDK reports NFC as not
available.
Add these keys to your app for the features your inquiries use. Without them iOS stops the app when the inquiry opens the camera, or refuses the NFC session:
| Feature | Key | Where | Value |
|---|---|---|---|
| Camera | NSCameraUsageDescription | Info.plist | Your text that says why the app uses the camera, for documents and selfies. |
| NFC | NFCReaderUsageDescription | Info.plist | Your text that says why the app reads the document chip. |
| NFC | com.apple.developer.nfc.readersession.iso7816.select-identifiers | Info.plist | An array with A0000002471001. |
| NFC | com.apple.developer.nfc.readersession.formats | App entitlements | An array with TAG. Add the NFC Tag Reading capability to your App ID. |
PHPickerViewController and UIDocumentPickerViewController need no usage description.
FolioSDK ships a privacy manifest, PrivacyInfo.xcprivacy, in a resource bundle that Xcode
copies into every app that links the library. It declares no tracking and two required-reason APIs:
file timestamps (C617.1) for the SDK's own files in the app container, and user defaults
(CA92.1) for values only your app reads. Xcode includes it in your app's privacy report.
Passkeys
The SDK offers passkeys in authentication with the relying party
that Folio names for the step: the root domain of the Folio environment, folio.id in production
and folio.mobi for https://app.folio.mobi. iOS lets an app use a passkey only when the app is
associated with that domain in both directions:
- Your app has the Associated Domains capability with the entry
webcredentials:<domain>, for examplewebcredentials:folio.id. - The domain's
apple-app-site-associationfile lists your app ID,<team id>.<bundle id>, underwebcredentials. Folio hosts this file; ask Folio to add your app ID.
Without both, iOS refuses the passkey request; the other methods in alternatives keep working.
Create the runtime
Your app works with the SDK through one runtime object, FolioSdk. Create it once with
FolioSdk.create(config:mapper:), then mount the stores your UI needs on it. See
Runtime.
create takes two arguments: a FolioSdkConfig and your IdentityRecordMapper. The mapper turns a
verified government id into the vault record your app wants to keep. Implement it in a class; the
runtime keeps it for its lifetime:
import FolioSDK
struct SavedIdentity: Codable {
let identities: [VerifiedIdentity]
}
struct NotASavedIdentity: LocalizedError {
let schema: String
var errorDescription: String? { "a \(schema) record is no saved identity" }
}
final class SavedIdentityMapper: IdentityRecordMapper {
func map(identity: VerifiedIdentity, existing: RecordContent?) throws -> RecordContent {
var earlier: [VerifiedIdentity] = []
if let existing {
guard existing.schema == "identity", existing.version == 1 else {
throw NotASavedIdentity(schema: existing.schema)
}
earlier = try JSONDecoder()
.decode(SavedIdentity.self, from: Data(existing.payload.utf8))
.identities
}
let document = SavedIdentity(identities: earlier + [identity])
let payload = String(decoding: try JSONEncoder().encode(document), as: UTF8.self)
return RecordContent(schema: "identity", version: 1, payload: payload)
}
}When a government id verification succeeds, the inquiry calls map(identity:existing:) and writes
the returned RecordContent to the vault. payload is your own JSON document and schema is your
name for it, without the organization. The SDK keeps one record per physical document: for a
document it saved before (same document number and date of birth) existing is that record and the
SDK updates it; otherwise existing is nil and the SDK creates a record. Selfie and OTP results
never reach the mapper. An error thrown from map writes nothing and ends the inquiry with
.failed(error: .recordMapping(message:)), whose message is the error's errorDescription. See
Identity records.
Then create the runtime:
import FolioSDK
import FolioInquiryUI
@MainActor
final class FolioModel: ObservableObject {
@Published private(set) var sdk: FolioSdk?
@Published private(set) var failure: String?
func start() async {
guard sdk == nil else { return }
do {
let config = FolioSdkConfig(
baseUrl: "https://app.folio.mobi",
organization: "<organization>",
development: nil,
logging: try FolioSdk.loggingConfig(consoleLevel: nil)
)
sdk = try await FolioSdk.create(config: config, mapper: SavedIdentityMapper())
} catch {
failure = String(describing: error)
}
}
}create is async and throws. Before it returns, the runtime restores the locale setting and the
session saved on the device. When it returns, the SDK is ready and you can mount stores. When it
throws, the SDK did not start. The error is a FolioSDKError with a code and a message that
describes why the start failed; the causes are the cases of SessionStartError (see
Errors). When the task that awaits create is cancelled, create throws
CancellationError.
Configuration
FolioSdkConfig takes these values. Configuration describes each one in
detail.
| Field | Type | Value |
|---|---|---|
baseUrl | String | The Folio backend origin, for example https://app.folio.mobi. The SDK calls its services under /api on this origin. |
organization | String | Your organization, <organization>: non-empty, lowercase letters, digits and hyphens. The vault stores your records under it. An invalid value makes create throw. |
development | DevelopmentConfig? | nil in a production app. DevelopmentConfig(realm:serviceEnvironments:) sets a realm and moves services to development environments. See Configuration. |
logging | LoggingConfig | File and console writers for this runtime; see Logging. |
The SDK reads your app's identity from the main bundle: CFBundleIdentifier,
CFBundleShortVersionString as the version and CFBundleVersion as the build. In a stamped build,
create throws when one of them is missing (see
Application identity). The SDK reports the version and
build in its user agent.
Close the runtime
Both calls are asynchronous. try await sdk.shutdown() unmounts every store, shuts the session
scope down and stops the runtime. try await sdk.close() shuts the runtime down and releases it.
It releases the runtime even when the shutdown throws, and a second call waits for the same close
instead of starting another one. Every later call on the runtime throws a FolioSDKError with the
message "Handle already released".
Provide the runtime to SwiftUI
FolioSDKProvider(runtime:content:) puts the runtime into the SwiftUI environment of its content.
FolioSdk is an ObservableObject, so a view below the provider reads it with
@EnvironmentObject:
import SwiftUI
import FolioSDK
@main
struct YourApp: App {
@StateObject private var folio = FolioModel()
var body: some Scene {
WindowGroup {
if let sdk = folio.sdk {
FolioSDKProvider(runtime: sdk) {
HomeView()
}
} else {
ProgressView()
.task { await folio.start() }
}
}
}
}
struct HomeView: View {
@EnvironmentObject private var sdk: FolioSdk
var body: some View {
Text("Ready")
}
}The provider only passes the runtime down. Mount the stores your views need on it, as described below.
Static helpers
Functions that need no runtime are static members of FolioSdk that throw, for example
FolioSdk.localize(key:locale:), FolioSdk.countries() and FolioSdk.sdkBuildInfo(). See
Static functions for the full list and
Localization.
Mount stores
The runtime returns no data. Every read and write goes through a store: you mount the store on the runtime, dispatch actions and read the result from its state.
let session = try SessionStore.mount(runtime: sdk)
let vault = try VaultStore.mount(runtime: sdk)
let inquiry = try InquiryStore.mount(runtime: sdk)
let documents = try FolioDocumentListStore.mount(runtime: sdk)
let document = try FolioDocumentStore.mount(runtime: sdk, init: FolioDocumentInit(id: documentId))LogsStore and FolioDocumentStore take a second argument, init: the LogFilter of the
journal and the FolioDocumentInit with the id of the document.
Each store is an ObservableObject with these members:
| Member | Meaning |
|---|---|
static func mount(runtime:) | Mounts the store on the runtime and throws when the runtime cannot mount it. A store exists only while it is mounted. FolioSdk is accepted wherever runtime is expected. |
state | @Published public private(set) and never optional. mount fills it and the SDK keeps it current. Updates arrive on the main actor. |
streamError | @Published public private(set), an optional Error. It is set on the main actor when the stream that keeps state current fails. |
func dispatch(action:context:) throws | Sends one action. context is an optional LogContext, nil by default, that ties the action's journal entries to an interaction; see Logging. Throws when the store is closed or the runtime rejects the action. |
func subscribe() throws | Returns a subscription, such as InquirySubscription, that is an AsyncSequence of the state. try await next() returns the current state first, then the latest state after each change, and nil once the store is unmounted. release() on the subscription, or releasing it, ends it. |
func close() | Unmounts the store. Calling it again does nothing; releasing the store does the same. |
Keep a strong reference to each store for as long as your UI uses it, and close it when you are
done. Observe a store from SwiftUI with @ObservedObject, or with Combine through $state:
let lifecycle = inquiry.$state
.map(\.lifecycle)
.sink { lifecycle in
if case let .closed(outcome) = lifecycle {
print("done:", String(describing: outcome))
}
}Read the saved identities back from the vault store and decode the payload yourself:
let vault = try VaultStore.mount(runtime: sdk)
let identities = try vault.state.records
.filter { $0.header.schema == "identity" }
.map { try JSONDecoder().decode(SavedIdentity.self, from: Data($0.payload.utf8)) }The actions and state of each store are described on their own pages: Session, Authentication, Vault, Inquiry store, NFC, Logging and Folio documents.
Run an inquiry
Your backend creates the inquiry and passes its launch code to your app. The inquiry UI renders
a mounted InquiryStore: you mount the store and send Open with an entry, the UI renders the
inquiry, and you close the store when the flow is over. The flow itself is described in
Inquiry UI.
The entry tells the store which inquiry to open:
| Entry | Use |
|---|---|
.start(launchCode:) | Starts the inquiry for the single-use launch code your backend received. |
.resume(source: .sameDevice(inquiryId:)) | Resumes an inquiry on this device. inquiryId is optional. |
.resume(source: .anotherDevice(code:)) | Continues on this device an inquiry started on another device, with its code. |
Show the flow
FolioInquiry is a SwiftUI view that renders a mounted InquiryStore:
public struct FolioInquiry: View {
public init(store: InquiryStore, platform: InquiryPlatform)
}
@MainActor
public struct InquiryPlatform {
public init(
nfc: any MrtdHost,
selfieCapture: SelfieCaptureProvider? = DefaultSelfieCaptureProvider()
)
}storeis the store you mounted. You sendInquiryAction.open(entry:)on it yourself, and you close it when the flow is over. The view never closes the store.platformholds what the flow needs from the device:nfcis the runtime that reads the chip, yourFolioSdk, andselfieCaptureis the selfie capture provider,DefaultSelfieCaptureProvider()unless you pass your own ornil.
import Combine
import SwiftUI
import FolioSDK
import FolioInquiryUI
@MainActor
final class VerifyModel: ObservableObject {
@Published private(set) var store: InquiryStore?
private var closing: AnyCancellable?
func start(sdk: FolioSdk, launchCode: String) throws {
let store = try InquiryStore.mount(runtime: sdk)
do {
try store.dispatch(action: .open(entry: .start(launchCode: launchCode)))
} catch {
store.close()
throw error
}
closing = store.$state
.map(\.lifecycle)
.sink { [weak self] lifecycle in
if case .closed = lifecycle { self?.finish() }
}
self.store = store
}
func finish() {
closing = nil
store?.close()
store = nil
}
}
struct VerifyView: View {
@ObservedObject var model: VerifyModel
let sdk: FolioSdk
var body: some View {
if let store = model.store {
FolioInquiry(
store: store,
platform: InquiryPlatform(nfc: sdk)
)
}
}
}In UIKit, host the view in a UIHostingController and dismiss it when lifecycle becomes
closed(outcome:). Whether the person can swipe the sheet away is up to you; if you present it in
a way that can be dismissed without the flow's own close, close the store yourself when that
happens.
The view sends every interaction to the store. After it sent InquiryAction.close it sends nothing
more until the store opens again, and an action that reaches a store already closed is dropped. Any
other error from dispatch is a programming error, and the view stops the app with
preconditionFailure.
How the flow ends
- When the person leaves the flow, or taps the close button of the failure notice, the view
dispatches
InquiryAction.close.lifecyclebecomesclosed(outcome:)only after that close. - When the inquiry ends, the terminal stage,
StageContent.terminal(outcome:redirect:), carries an optional redirect asLinkTarget.universal(url:). While the terminal stage shows,lifecyclestaysstarted(correlationId:). Once the store has finished loading, the view opens the redirect withUIApplication.shared.open, so iOS handles it as a universal link: an app that claims the URL opens, otherwise the browser does. The view closes the inquiry once the redirect opened, or at once when there is none. - Every other link of the flow is opened the same way. When iOS refuses a URL, the view sends
InquiryIntent.linkRefused(url:)and shows the error the store projects. At the end of the inquiry, that error has a close button that closes the inquiry. - The SDK validates and normalizes every link and redirect of an inquiry before the view sees it. A
URL that does not parse as an absolute URL fails with
InquiryError.invalidLink(url:). - The store reports the result in
InquiryUiModel.lifecycle:ready(sdkVersion:),started(correlationId:),closed(outcome:)andfailed(error:).failedis informational: the view still shows the failure notice and the person leaves through its close button. Close the store onclosed, not onfailed.outcomeisnilor aTerminalOutcome:.completed,.failed,.error,.canceled,.expiredor.needsReview. See Inquiry store. - There are no exit callbacks, event enums or error codes in the UI package: observe the store state.
Errors in the flow
The errors the flow shows come from InquiryUiModel.error, an InquiryErrorView with text,
placement and retryable. placement is .field(field:) for an error that belongs to a field,
shown by the screen that owns the field, or .banner, shown above the stage.
When the inquiry cannot go on, for example because the backend rejected the launch code or a resume
cannot continue, InquiryUiModel.failure is set and the view shows it instead of any stage: its
title and description, a retry button when retry is set, which sends InquiryIntent.retry,
and a close button labelled close, which sends InquiryAction.close. The SDK picks all the copy.
See Failure notice and Errors.
Selfie capture
The selfie step calls the SelfieCaptureProvider of the InquiryPlatform, which is
DefaultSelfieCaptureProvider() from FolioInquiryUI by default. To replace it, pass your own
implementation of the protocol as selfieCapture::
@MainActor
public protocol SelfieCaptureProvider {
func startLiveness(
_ capture: SelfieCaptureView,
serviceUrl: String,
presenter: UIViewController
) async throws -> SelfieLivenessCapture
}SelfieCaptureView carries the captureSessionId of the capture session, the liveness mode,
the headers for the liveness service and the texts to show. The provider returns
SelfieLivenessCapture(image:transactionId:). A provider that throws, for example
SelfieCaptureError.dismissed, .engineUnavailable or .failed, takes the person one step back.
Without a provider, or without a liveness service URL, the selfie step shows its processing screen
and cannot capture.
A selfie photo step, one that takes a still photo instead of a liveness check, does not use the
provider: it opens the system image picker with the front camera. An image that cannot be read is
sent as InquiryIntent.fileUnreadable(field:), and the step shows the error on its capture field.
A selfie photo step that the inquiry defines without a capture field fails with
InquiryError.missingCaptureField(step:). See Selfie capture.
FolioInquiryUI.bundle is the resource bundle of the FolioInquiryUI target.
Logging
On iOS the console writer of FolioSdkConfig.logging writes to unified logging, with subsystem
id.folio.sdk and category general. Each entry reads [target] message. The SDK levels map to
OSLogType as follows:
LogLevel | OSLogType |
|---|---|
trace, debug | .debug |
info | .info |
warn | .default |
error | .error |
FolioSdk.loggingConfig(consoleLevel:) adds a console writer at the level you pass to the file
writer of the log journal; with nil the SDK writes the journal only and nothing reaches unified
logging. Each runtime configures its own writers. See Logging.
Get started
The packages, the prerequisites and the integration steps of the Folio SDK on iOS, Android and the web, with a minimal app per platform.
Android setup
Add the Folio SDK to an Android app, initialize the platform, create the runtime, mount stores and run an inquiry with the Compose inquiry UI.