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:

ProductContents
FolioSDKThe 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.
FolioInquiryUIThe 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.
FolioSDKBinaryFolioSDK.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:

Package.swift
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 FolioInquiryUI

A 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.

FrameworkFrameworkFramework
FoundationSecurityNetwork
AuthenticationServicesDeviceCheckLocalAuthentication
UserNotificationsBackgroundTasksCoreGraphics
ImageIOCoreServicesCoreNFC

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:

FeatureWhat the SDK does
CameraThe 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 cameraThe 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.
NFCThe 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, filesWhen 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:

FeatureKeyWhereValue
CameraNSCameraUsageDescriptionInfo.plistYour text that says why the app uses the camera, for documents and selfies.
NFCNFCReaderUsageDescriptionInfo.plistYour text that says why the app reads the document chip.
NFCcom.apple.developer.nfc.readersession.iso7816.select-identifiersInfo.plistAn array with A0000002471001.
NFCcom.apple.developer.nfc.readersession.formatsApp entitlementsAn 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 example webcredentials:folio.id.
  • The domain's apple-app-site-association file lists your app ID, <team id>.<bundle id>, under webcredentials. 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:

SavedIdentityMapper.swift
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:

FolioModel.swift
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.

FieldTypeValue
baseUrlStringThe Folio backend origin, for example https://app.folio.mobi. The SDK calls its services under /api on this origin.
organizationStringYour organization, <organization>: non-empty, lowercase letters, digits and hyphens. The vault stores your records under it. An invalid value makes create throw.
developmentDevelopmentConfig?nil in a production app. DevelopmentConfig(realm:serviceEnvironments:) sets a realm and moves services to development environments. See Configuration.
loggingLoggingConfigFile 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:

YourApp.swift
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:

MemberMeaning
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:) throwsSends 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() throwsReturns 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:

EntryUse
.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()
    )
}
  • store is the store you mounted. You send InquiryAction.open(entry:) on it yourself, and you close it when the flow is over. The view never closes the store.
  • platform holds what the flow needs from the device: nfc is the runtime that reads the chip, your FolioSdk, and selfieCapture is the selfie capture provider, DefaultSelfieCaptureProvider() unless you pass your own or nil.
VerifyView.swift
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. lifecycle becomes closed(outcome:) only after that close.
  • When the inquiry ends, the terminal stage, StageContent.terminal(outcome:redirect:), carries an optional redirect as LinkTarget.universal(url:). While the terminal stage shows, lifecycle stays started(correlationId:). Once the store has finished loading, the view opens the redirect with UIApplication.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:) and failed(error:). failed is informational: the view still shows the failure notice and the person leaves through its close button. Close the store on closed, not on failed. outcome is nil or a TerminalOutcome: .completed, .failed, .error, .canceled, .expired or .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:

LogLevelOSLogType
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.

On this page