37 KiB
Person 2: KMP SDK / Native Handlers — Implementation Spec
Current Status
| Chunk | Description | Status |
|---|---|---|
| 2A | KMP Setup + Bridge Protocol | ✅ Complete |
| 2B | Android WebView Host | ✅ Complete |
| 2C | Android Native Handlers | ✅ Complete (all 9) |
| 2D | iOS WebView Host + cinterop | ⚠️ Partial (cinterop blocked by Xcode SDK compatibility issues, stubs in place) |
| 2E | iOS Native Handlers | ❌ Not Done (all 9 handlers are stubs throwing NotImplementedError) |
| 2F | SDK Public API + Test App | ⚠️ Partial (Android works end-to-end, iOS uses Swift workarounds via factory pattern in test app) |
Note: Remaining iOS handler work has moved to SPEC-IOS-HANDLERS.md — uses a Swift wrapper pattern instead of cinterop. The native proving client (for headless SDK use without WebView) is specified in SPEC-PROVING-CLIENT.md. A MiniPay sample app demonstrating the headless flow is in SPEC-MINIPAY-SAMPLE.md.
Overview
You are building the native side of the Self Mobile SDK. This means:
packages/kmp-sdk/— Kotlin Multiplatform module withshared/source setspackages/kmp-test-app/— Test app for both Android and iOS
The KMP SDK:
- Hosts a WebView containing Person 1's Vite bundle
- Routes bridge messages from the WebView to native handlers
- Provides
SelfSdk.launch()as the public API for host apps (MiniPay, etc.) - Outputs: AAR (Android) + XCFramework/SPM (iOS)
What to Delete First
Delete packages/kmp-shell/ entirely before starting. It was an experiment — the bridge protocol and handler pattern are sound, but the module structure needs to be rebuilt as a proper KMP SDK with Android target (not just JVM + iOS).
Directory Structure
packages/kmp-sdk/
shared/
src/
commonMain/kotlin/xyz/self/sdk/
bridge/
BridgeMessage.kt # @Serializable protocol types
BridgeHandler.kt # Handler interface + BridgeHandlerException
MessageRouter.kt # Routes messages to handlers, sends responses
models/
PassportScanResult.kt # Common NFC result model
NfcScanProgress.kt # Progress events
NfcScanParams.kt # Scan parameters
MrzKeyUtils.kt # MRZ key derivation (pure Kotlin)
api/
SelfSdk.kt # expect class — public API
SelfSdkConfig.kt # Configuration data class
VerificationRequest.kt # Request model
SelfSdkCallback.kt # Result callback interface
webview/
WebViewHost.kt # expect class — WebView hosting
commonTest/kotlin/xyz/self/sdk/
bridge/
MessageRouterTest.kt
models/
MrzKeyUtilsTest.kt
androidMain/kotlin/xyz/self/sdk/
api/
SelfSdk.android.kt # actual class — Android implementation
webview/
AndroidWebViewHost.kt # Android WebView + JS injection
SelfVerificationActivity.kt # Activity wrapping the WebView
handlers/
NfcBridgeHandler.kt # JMRTD passport reader
BiometricBridgeHandler.kt # BiometricPrompt
SecureStorageBridgeHandler.kt # EncryptedSharedPreferences
CryptoBridgeHandler.kt # Java Security Provider
CameraMrzBridgeHandler.kt # ML Kit Text Recognition
HapticBridgeHandler.kt # Vibration feedback
AnalyticsBridgeHandler.kt # Fire-and-forget logging
LifecycleBridgeHandler.kt # WebView → host communication
DocumentsBridgeHandler.kt # Encrypted document storage
iosMain/kotlin/xyz/self/sdk/
api/
SelfSdk.ios.kt # actual class — iOS implementation
webview/
IosWebViewHost.kt # WKWebView + JS injection
handlers/
NfcBridgeHandler.kt # CoreNFC via cinterop
BiometricBridgeHandler.kt # LAContext via cinterop
SecureStorageBridgeHandler.kt # Keychain via cinterop
CryptoBridgeHandler.kt # CommonCrypto via cinterop
CameraMrzBridgeHandler.kt # Vision framework via cinterop
HapticBridgeHandler.kt # UIImpactFeedbackGenerator
AnalyticsBridgeHandler.kt # Fire-and-forget logging
LifecycleBridgeHandler.kt # WebView → host communication
DocumentsBridgeHandler.kt # Encrypted document storage
nativeInterop/
cinterop/
CoreNFC.def
LocalAuthentication.def
Security.def
Vision.def
build.gradle.kts # KMP plugin, Android + iOS targets
packages/kmp-test-app/
shared/ # Shared KMP app code
androidApp/ # Android test app (Compose)
iosApp/ # iOS test app (SwiftUI)
build.gradle.kts
Gradle Configuration
packages/kmp-sdk/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidLibrary) // NEW: Android library target
id("maven-publish") // For AAR publishing
}
kotlin {
jvm() // For unit tests on JVM
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "17"
}
}
publishLibraryVariants("release")
}
iosArm64()
iosSimulatorArm64()
// iOS framework for SPM distribution
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "SelfSdk"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
val androidMain by getting {
dependencies {
// WebView
implementation("androidx.webkit:webkit:1.12.1")
// NFC / Passport
implementation("org.jmrtd:jmrtd:0.8.1")
implementation("net.sf.scuba:scuba-sc-android:0.0.18")
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
implementation("commons-io:commons-io:2.14.0")
// Biometrics
implementation("androidx.biometric:biometric:1.2.0-alpha05")
// Encrypted storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Camera / MRZ
implementation("com.google.mlkit:text-recognition:16.0.0")
// Activity / Lifecycle
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
}
}
}
}
android {
namespace = "xyz.self.sdk"
compileSdk = 35
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Bundle WebView assets
sourceSets["main"].assets.srcDirs("src/main/assets")
}
Bridge Protocol (Kotlin Side)
The bridge protocol is the shared contract with Person 1. The Kotlin implementation mirrors the TypeScript types exactly.
BridgeMessage.kt
package xyz.self.sdk.bridge
import kotlinx.serialization.*
import kotlinx.serialization.json.JsonElement
const val BRIDGE_PROTOCOL_VERSION = 1
@Serializable
enum class BridgeDomain {
@SerialName("nfc") NFC,
@SerialName("biometrics") BIOMETRICS,
@SerialName("secureStorage") SECURE_STORAGE,
@SerialName("camera") CAMERA,
@SerialName("crypto") CRYPTO,
@SerialName("haptic") HAPTIC,
@SerialName("analytics") ANALYTICS,
@SerialName("lifecycle") LIFECYCLE,
@SerialName("documents") DOCUMENTS,
@SerialName("navigation") NAVIGATION,
}
@Serializable
data class BridgeError(
val code: String,
val message: String,
val details: Map<String, JsonElement>? = null,
)
@Serializable
data class BridgeRequest(
val type: String = "request",
val version: Int,
val id: String,
val domain: BridgeDomain,
val method: String,
val params: Map<String, JsonElement>,
val timestamp: Long,
)
@Serializable
data class BridgeResponse(
val type: String = "response",
val version: Int = BRIDGE_PROTOCOL_VERSION,
val id: String,
val domain: BridgeDomain,
val requestId: String,
val success: Boolean,
val data: JsonElement? = null,
val error: BridgeError? = null,
val timestamp: Long = currentTimeMillis(),
)
@Serializable
data class BridgeEvent(
val type: String = "event",
val version: Int = BRIDGE_PROTOCOL_VERSION,
val id: String,
val domain: BridgeDomain,
val event: String,
val data: JsonElement,
val timestamp: Long = currentTimeMillis(),
)
// Platform expect/actual for time and UUID
internal expect fun currentTimeMillis(): Long
internal expect fun generateUuid(): String
Platform actuals:
- JVM/Android:
System.currentTimeMillis(),java.util.UUID.randomUUID().toString() - iOS:
NSDate().timeIntervalSince1970 * 1000,NSUUID().UUIDString
BridgeHandler.kt
interface BridgeHandler {
val domain: BridgeDomain
suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement?
}
class BridgeHandlerException(
val code: String,
override val message: String,
val details: Map<String, JsonElement>? = null,
) : Exception(message)
MessageRouter.kt
Routes incoming messages from WebView to handlers, runs them on a coroutine scope, sends responses back via a sendToWebView callback.
Key behavior:
register(handler): Register aBridgeHandlerfor a domainonMessageReceived(rawJson): Parse request, find handler, dispatch on coroutine scopepushEvent(domain, event, data): Send unsolicited events to WebView- Response delivery:
window.SelfNativeBridge._handleResponse('...') - Event delivery:
window.SelfNativeBridge._handleEvent('...')
JS escaping for safe embedding:
fun escapeForJs(json: String): String {
val escaped = json
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "'$escaped'"
}
Android Implementation
AndroidWebViewHost.kt
Manages an Android WebView instance:
class AndroidWebViewHost(
private val context: Context,
private val router: MessageRouter,
) {
private lateinit var webView: WebView
fun createWebView(): WebView {
webView = WebView(context).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false // Security
allowContentAccess = false
mediaPlaybackRequiresUserGesture = false
}
// JS interface: WebView → Native
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
// Load bundled assets or dev server
if (BuildConfig.DEBUG) {
loadUrl("http://10.0.2.2:5173")
} else {
loadUrl("file:///android_asset/self-wallet/index.html")
}
}
return webView
}
// Send response/event to WebView
fun evaluateJs(js: String) {
webView.evaluateJavascript(js, null)
}
inner class BridgeJsInterface {
@JavascriptInterface
fun postMessage(json: String) {
router.onMessageReceived(json)
}
}
}
SelfVerificationActivity.kt
An Activity that hosts the WebView. Host apps launch this via SelfSdk.launch():
class SelfVerificationActivity : AppCompatActivity() {
private lateinit var webViewHost: AndroidWebViewHost
private lateinit var router: MessageRouter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Create router with callback to send JS to WebView
router = MessageRouter(
sendToWebView = { js -> runOnUiThread { webViewHost.evaluateJs(js) } }
)
// Register all native handlers
router.register(NfcBridgeHandler(this, router))
router.register(BiometricBridgeHandler(this))
router.register(SecureStorageBridgeHandler(this))
router.register(CryptoBridgeHandler())
router.register(CameraMrzBridgeHandler(this))
router.register(HapticBridgeHandler(this))
router.register(AnalyticsBridgeHandler())
router.register(LifecycleBridgeHandler(this))
router.register(DocumentsBridgeHandler(this))
// Create and show WebView
webViewHost = AndroidWebViewHost(this, router)
setContentView(webViewHost.createWebView())
}
}
NfcBridgeHandler.kt (Android)
This is the most complex handler. Port from app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt.
Key changes from the RN module:
- Remove all React Native dependencies (
ReactApplicationContext,Promise,WritableMap,ReadableMap,DeviceEventManagerModule) - Replace
AsyncTaskwith Kotlin coroutines (suspend fun) - Use
NfcAdapter.enableReaderMode()instead ofenableForegroundDispatch()(better for SDK embedding — doesn't require the host's Activity to handle intents) - Send progress updates via
router.pushEvent()instead of React Native event emitter - Return structured
PassportScanResultinstead of React NativeWritableMap
class NfcBridgeHandler(
private val activity: Activity,
private val router: MessageRouter,
) : BridgeHandler {
override val domain = BridgeDomain.NFC
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
return when (method) {
"scan" -> scan(params)
"cancelScan" -> cancelScan()
"isSupported" -> isSupported()
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown NFC method: $method")
}
}
private suspend fun scan(params: Map<String, JsonElement>): JsonElement {
val scanParams = Json.decodeFromJsonElement<NfcScanParams>(JsonObject(params))
// Derive BAC key from MRZ data
val mrzKey = MrzKeyUtils.computeMrzInfo(
scanParams.passportNumber,
scanParams.dateOfBirth,
scanParams.dateOfExpiry,
)
// Wait for NFC tag using enableReaderMode (coroutine-friendly)
val tag = awaitNfcTag()
// Open IsoDep connection
val isoDep = IsoDep.get(tag)
isoDep.timeout = 20_000
try {
val cardService = CardService.getInstance(isoDep)
cardService.open()
val service = PassportService(
cardService,
PassportService.NORMAL_MAX_TRANCEIVE_LENGTH * 2,
PassportService.DEFAULT_MAX_BLOCKSIZE * 2,
false, false,
)
service.open()
// PACE attempt
pushProgress("pace", 10, "Attempting PACE authentication...")
var paceSucceeded = tryPACE(service, scanParams)
// BAC fallback
if (!paceSucceeded) {
pushProgress("bac", 20, "Attempting BAC authentication...")
val bacKey = BACKey(scanParams.passportNumber, scanParams.dateOfBirth, scanParams.dateOfExpiry)
tryBAC(service, bacKey)
}
// Read data groups
pushProgress("reading_dg1", 40, "Reading DG1...")
val dg1File = DG1File(service.getInputStream(PassportService.EF_DG1))
pushProgress("reading_sod", 60, "Reading SOD...")
val sodFile = SODFile(service.getInputStream(PassportService.EF_SOD))
// Chip authentication
pushProgress("chip_auth", 80, "Chip authentication...")
doChipAuth(service)
pushProgress("complete", 100, "Scan complete")
// Build result matching PassportScanResult
return buildPassportResult(dg1File, sodFile)
} finally {
isoDep.close()
}
}
}
NFC flow (from RNPassportReaderModule, simplified):
- Get
NfcAdapter, checkisEnabled - Wait for tag via
enableReaderMode(orenableForegroundDispatch) - Get
IsoDepfrom tag, set timeout to 20s - Create
CardService, open it - Create
PassportService, open it - PACE attempt: Read
EF_CARD_ACCESS→ extractPACEInfo→service.doPACE() - BAC fallback (if PACE fails):
service.sendSelectApplet(false)→service.doBAC(bacKey)with up to 3 retries - Select applet after auth:
service.sendSelectApplet(true) - Read DG1:
DG1File(service.getInputStream(PassportService.EF_DG1)) - Read SOD:
SODFile(service.getInputStream(PassportService.EF_SOD)) - Chip Authentication: Read DG14 → extract
ChipAuthenticationPublicKeyInfo→service.doEACCA() - Build result: Extract MRZ, certificates, hashes, signatures from parsed files
Dependencies:
org.jmrtd:jmrtd:0.8.1net.sf.scuba:scuba-sc-android:0.0.18org.bouncycastle:bcprov-jdk18on:1.78.1commons-io:commons-io:2.14.0
BiometricBridgeHandler.kt (Android)
class BiometricBridgeHandler(private val activity: FragmentActivity) : BridgeHandler {
override val domain = BridgeDomain.BIOMETRICS
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
return when (method) {
"authenticate" -> authenticate(params)
"isAvailable" -> isAvailable()
"getBiometryType" -> getBiometryType()
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown biometrics method: $method")
}
}
private suspend fun authenticate(params: Map<String, JsonElement>): JsonElement {
val reason = params["reason"]?.jsonPrimitive?.content ?: "Authenticate"
return suspendCancellableCoroutine { cont ->
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Self Verification")
.setSubtitle(reason)
.setNegativeButtonText("Cancel")
.build()
val prompt = BiometricPrompt(activity, /* executor */, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
cont.resume(JsonPrimitive(true))
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
cont.resumeWithException(BridgeHandlerException("BIOMETRIC_ERROR", errString.toString()))
}
override fun onAuthenticationFailed() {
cont.resumeWithException(BridgeHandlerException("BIOMETRIC_FAILED", "Authentication failed"))
}
})
prompt.authenticate(promptInfo)
}
}
}
SecureStorageBridgeHandler.kt (Android)
Uses EncryptedSharedPreferences backed by Android Keystore:
class SecureStorageBridgeHandler(context: Context) : BridgeHandler {
override val domain = BridgeDomain.SECURE_STORAGE
private val prefs = EncryptedSharedPreferences.create(
"self_sdk_secure_prefs",
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
val key = params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
return when (method) {
"get" -> {
val value = prefs.getString(key, null)
if (value != null) JsonPrimitive(value) else JsonNull
}
"set" -> {
val value = params["value"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_VALUE", "Value parameter required")
prefs.edit().putString(key, value).apply()
null
}
"remove" -> {
prefs.edit().remove(key).apply()
null
}
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown secureStorage method: $method")
}
}
}
CryptoBridgeHandler.kt (Android)
class CryptoBridgeHandler : BridgeHandler {
override val domain = BridgeDomain.CRYPTO
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
return when (method) {
"sign" -> sign(params)
"generateKey" -> generateKey(params)
"getPublicKey" -> getPublicKey(params)
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown crypto method: $method")
}
}
private fun sign(params: Map<String, JsonElement>): JsonElement {
val dataBase64 = params["data"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_DATA", "Data parameter required")
val keyRef = params["keyRef"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY_REF", "keyRef parameter required")
val data = Base64.decode(dataBase64, Base64.NO_WRAP)
// Load key from Android Keystore
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val entry = keyStore.getEntry(keyRef, null) as? KeyStore.PrivateKeyEntry
?: throw BridgeHandlerException("KEY_NOT_FOUND", "Key not found: $keyRef")
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(entry.privateKey)
signature.update(data)
val signed = signature.sign()
return buildJsonObject {
put("signature", Base64.encodeToString(signed, Base64.NO_WRAP))
}
}
}
Other Android Handlers (simpler)
HapticBridgeHandler: Vibrator.vibrate(VibrationEffect.createOneShot(...))
AnalyticsBridgeHandler: Log to Logcat or forward to host app's analytics. Fire-and-forget (always return null).
LifecycleBridgeHandler: ready = no-op, dismiss = activity.finish(), setResult = set Activity result and finish.
DocumentsBridgeHandler: Uses EncryptedSharedPreferences to store JSON-serialized documents.
CameraMrzBridgeHandler: Uses ML Kit TextRecognition to detect MRZ text from camera preview.
iOS Implementation
Kotlin/Native cinterop
iOS handlers are written in Kotlin using cinterop to call Apple frameworks.
CoreNFC.def
language = Objective-C
headers =
modules = CoreNFC
linkerOpts = -framework CoreNFC
LocalAuthentication.def
language = Objective-C
modules = LocalAuthentication
linkerOpts = -framework LocalAuthentication
Security.def
language = Objective-C
modules = Security
linkerOpts = -framework Security
Vision.def (for MRZ scanning)
language = Objective-C
modules = Vision
linkerOpts = -framework Vision
Add to build.gradle.kts:
iosArm64 {
compilations["main"].cinterops {
create("CoreNFC")
create("LocalAuthentication")
create("Security")
create("Vision")
}
}
iosSimulatorArm64 {
compilations["main"].cinterops {
create("CoreNFC") // Note: NFC won't work on simulator, but it needs to compile
create("LocalAuthentication")
create("Security")
create("Vision")
}
}
IosWebViewHost.kt
import platform.WebKit.*
import platform.Foundation.*
actual class IosWebViewHost {
private lateinit var webView: WKWebView
fun createWebView(): WKWebView {
val config = WKWebViewConfiguration()
// Register message handler: WebView → Native
val handler = BridgeMessageHandler(router)
config.userContentController.addScriptMessageHandler(handler, "SelfNativeIOS")
webView = WKWebView(frame = CGRectZero, configuration = config)
// Load bundled HTML from framework resources
val bundleUrl = NSBundle.mainBundle.URLForResource("self-wallet/index", withExtension = "html")
if (bundleUrl != null) {
webView.loadFileURL(bundleUrl, allowingReadAccessToURL = bundleUrl.URLByDeletingLastPathComponent!!)
}
return webView
}
fun evaluateJs(js: String) {
webView.evaluateJavaScript(js, completionHandler = null)
}
}
class BridgeMessageHandler(private val router: MessageRouter) : NSObject(), WKScriptMessageHandlerProtocol {
override fun userContentController(
userContentController: WKUserContentController,
didReceiveScriptMessage: WKScriptMessage,
) {
val body = didReceiveScriptMessage.body as? String ?: return
router.onMessageReceived(body)
}
}
NfcBridgeHandler.kt (iOS)
Important: iOS NFC passport reading is significantly more complex than Android because:
- CoreNFC is Objective-C/Swift and the Kotlin/Native interop can be tricky
- The existing
app/ios/PassportReader.swiftuses the third-partyNFCPassportReaderSwift library (CocoaPod) - Pure Kotlin/Native CoreNFC interop for passport reading (PACE, BAC, data group parsing) is very hard
Recommended approach: Create a thin Objective-C/Swift wrapper exposed via @objc that Kotlin can call through cinterop. The wrapper does the heavy lifting (calling NFCPassportReader library), and the Kotlin handler just bridges the JSON params.
Alternatively, if you want pure Kotlin, you'd need to implement the entire ICAO 9303 protocol (BAC, PACE, secure messaging, ASN.1 parsing) which is months of work. The pragmatic approach is:
// iOS NFC handler — calls into Swift helper via cinterop
class NfcBridgeHandler(private val router: MessageRouter) : BridgeHandler {
override val domain = BridgeDomain.NFC
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
return when (method) {
"scan" -> scan(params)
"cancelScan" -> null // NFCPassportReader handles its own UI/cancel
"isSupported" -> JsonPrimitive(NFCReaderSession.readingAvailable)
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown NFC method: $method")
}
}
private suspend fun scan(params: Map<String, JsonElement>): JsonElement {
// Parse params, call into NFCPassportReaderWrapper (ObjC-exposed Swift)
// The wrapper returns a JSON string with passport data
// Parse and return as JsonElement
}
}
Reference: The iOS flow from app/ios/PassportReader.swift:
- Compute MRZ key (pad, checksum — same as Kotlin
MrzKeyUtils) - Call
passportReader.readPassport(password: mrzKey, type: .mrz, tags: [.COM, .DG1, .SOD]) - Extract fields from passport object (documentType, MRZ, certificates, etc.)
- Extract SOD data:
sod.getEncapsulatedContent(),sod.getSignedAttributes(),sod.getSignature() - Return structured result
BiometricBridgeHandler.kt (iOS)
import platform.LocalAuthentication.*
class BiometricBridgeHandler : BridgeHandler {
override val domain = BridgeDomain.BIOMETRICS
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
return when (method) {
"authenticate" -> authenticate(params)
"isAvailable" -> isAvailable()
"getBiometryType" -> getBiometryType()
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown method: $method")
}
}
private suspend fun authenticate(params: Map<String, JsonElement>): JsonElement {
val reason = params["reason"]?.jsonPrimitive?.content ?: "Authenticate"
val context = LAContext()
return suspendCancellableCoroutine { cont ->
context.evaluatePolicy(
LAPolicy.LAPolicyDeviceOwnerAuthenticationWithBiometrics,
localizedReason = reason,
) { success, error ->
if (success) {
cont.resume(JsonPrimitive(true))
} else {
cont.resumeWithException(
BridgeHandlerException("BIOMETRIC_ERROR", error?.localizedDescription ?: "Unknown error")
)
}
}
}
}
private fun isAvailable(): JsonElement {
val context = LAContext()
val canEvaluate = context.canEvaluatePolicy(LAPolicy.LAPolicyDeviceOwnerAuthenticationWithBiometrics, error = null)
return JsonPrimitive(canEvaluate)
}
private fun getBiometryType(): JsonElement {
val context = LAContext()
context.canEvaluatePolicy(LAPolicy.LAPolicyDeviceOwnerAuthenticationWithBiometrics, error = null)
return when (context.biometryType) {
LABiometryType.LABiometryTypeFaceID -> JsonPrimitive("faceId")
LABiometryType.LABiometryTypeTouchID -> JsonPrimitive("touchId")
else -> JsonPrimitive("none")
}
}
}
SecureStorageBridgeHandler.kt (iOS)
Uses Keychain Services via Security framework cinterop:
import platform.Security.*
import platform.Foundation.*
class SecureStorageBridgeHandler : BridgeHandler {
override val domain = BridgeDomain.SECURE_STORAGE
// Keychain operations using SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete
// with kSecClassGenericPassword, kSecAttrService = "xyz.self.sdk", kSecAttrAccount = key
}
CryptoBridgeHandler.kt (iOS)
Uses CommonCrypto or Security framework for signing:
import platform.Security.*
class CryptoBridgeHandler : BridgeHandler {
override val domain = BridgeDomain.CRYPTO
// Use SecKeyCreateSignature for signing
// Keys stored in Keychain with kSecAttrKeyTypeECSECPrimeRandom
}
Public API
SelfSdk.kt (commonMain — expect)
expect class SelfSdk {
companion object {
fun configure(config: SelfSdkConfig): SelfSdk
}
fun launch(request: VerificationRequest, callback: SelfSdkCallback)
}
SelfSdkConfig.kt
data class SelfSdkConfig(
val endpoint: String = "https://api.self.xyz",
val debug: Boolean = false,
)
VerificationRequest.kt
data class VerificationRequest(
val userId: String? = null,
val scope: String? = null,
val disclosures: List<String> = emptyList(),
)
SelfSdkCallback.kt
interface SelfSdkCallback {
fun onSuccess(result: VerificationResult)
fun onFailure(error: SelfSdkError)
fun onCancelled()
}
data class VerificationResult(
val success: Boolean,
val userId: String?,
val verificationId: String?,
val proof: String?,
val claims: Map<String, String>?,
)
data class SelfSdkError(
val code: String,
val message: String,
)
SelfSdk.android.kt (actual)
actual class SelfSdk private constructor(private val config: SelfSdkConfig) {
actual companion object {
actual fun configure(config: SelfSdkConfig): SelfSdk = SelfSdk(config)
}
actual fun launch(request: VerificationRequest, callback: SelfSdkCallback) {
// Start SelfVerificationActivity
// Pass request via Intent extras
// Register ActivityResult callback to receive result
// Call callback.onSuccess/onFailure/onCancelled based on result
}
}
SelfSdk.ios.kt (actual)
actual class SelfSdk private constructor(private val config: SelfSdkConfig) {
actual companion object {
actual fun configure(config: SelfSdkConfig): SelfSdk = SelfSdk(config)
}
actual fun launch(request: VerificationRequest, callback: SelfSdkCallback) {
// Create UIViewController with WKWebView
// Present it modally from the current UIViewController
// Register lifecycle handler to receive setResult and deliver via callback
}
}
Common Models (from prototype — keep as-is)
MrzKeyUtils.kt
Pure Kotlin, already correct in the prototype. ICAO 9303 check digit computation with [7, 3, 1] weighting.
PassportScanResult.kt / NfcScanProgress.kt / NfcScanParams.kt
@Serializable data classes matching the TypeScript types in the bridge protocol spec. Already correct in the prototype.
Asset Bundling
How WebView HTML gets into the SDK
Android: Gradle task copies Vite output (dist/) into src/main/assets/self-wallet/:
// In build.gradle.kts
tasks.register<Copy>("copyWebViewAssets") {
from("../../packages/webview-app/dist")
into("src/main/assets/self-wallet")
}
tasks.named("preBuild") { dependsOn("copyWebViewAssets") }
iOS: XCFramework/SPM includes the bundle as a resource bundle.
Dev mode: Load from http://10.0.2.2:5173 (Android emulator) or http://localhost:5173 (iOS simulator) instead of bundled assets.
Chunking Guide (Claude Code Sessions)
Chunk 2A: KMP Project Setup + Bridge Protocol (start here)
Goal: Create packages/kmp-sdk/ with Gradle KMP config, bridge protocol, common models.
Steps:
- Delete
packages/kmp-shell/ - Create
packages/kmp-sdk/directory structure - Create
build.gradle.ktswith KMP plugin, Android + iOS targets - Create
settings.gradle.kts,gradle.properties,libs.versions.toml - Implement
commonMain/bridge/— BridgeMessage, BridgeHandler, MessageRouter - Implement
commonMain/models/— MrzKeyUtils, PassportScanResult, NfcScanParams, NfcScanProgress - Implement platform actuals (jvmMain, iosMain) for
currentTimeMillis()andgenerateUuid() - Write unit tests in
commonTest/ - Validate:
./gradlew :shared:compileKotlinJvm && ./gradlew :shared:jvmTest
Chunk 2B: Android WebView Host
Goal: Android WebView hosting, JS injection, dev mode, asset bundling.
Steps:
- Implement
androidMain/webview/AndroidWebViewHost.kt - Implement
androidMain/webview/SelfVerificationActivity.kt - Configure WebView security settings
- Set up dev mode URL loading (
http://10.0.2.2:5173) - Create Gradle task for copying Vite
dist/into assets - Validate:
./gradlew :shared:compileDebugKotlinAndroid
Chunk 2C: Android Native Handlers
Goal: All Android bridge handlers.
Steps (in priority order):
NfcBridgeHandler— port fromRNPassportReaderModule.kt(biggest effort)BiometricBridgeHandler— BiometricPrompt wrapperSecureStorageBridgeHandler— EncryptedSharedPreferencesCryptoBridgeHandler— Android Keystore signingDocumentsBridgeHandler— JSON CRUD on encrypted storageLifecycleBridgeHandler— Activity result deliveryHapticBridgeHandler— VibrationAnalyticsBridgeHandler— LoggingCameraMrzBridgeHandler— ML Kit text recognition- Validate: compile + unit tests
Chunk 2D: iOS WebView Host + cinterop
Goal: iOS WebView hosting, cinterop definitions.
Steps:
- Create
.deffiles for CoreNFC, LocalAuthentication, Security, Vision - Implement
iosMain/webview/IosWebViewHost.kt - Configure WKWebView with WKScriptMessageHandler
- Validate:
./gradlew :shared:compileKotlinIosArm64
Chunk 2E: iOS Native Handlers
Goal: All iOS bridge handlers.
Steps:
BiometricBridgeHandler— LAContext (simplest, good to start)SecureStorageBridgeHandler— Keychain ServicesCryptoBridgeHandler— SecKey signingHapticBridgeHandler— UIImpactFeedbackGeneratorAnalyticsBridgeHandler— os_log or similarLifecycleBridgeHandler— ViewController dismissalDocumentsBridgeHandler— Encrypted file storageNfcBridgeHandler— CoreNFC (most complex, may need Swift wrapper)CameraMrzBridgeHandler— Vision framework- Validate: compile for iOS targets
Chunk 2F: SDK Public API + Test App
Goal: Public API + test app on both platforms.
Steps:
- Implement
commonMain/api/SelfSdk.kt(expect) + actuals - Create
packages/kmp-test-app/with Compose Multiplatform - Android test app: "Launch Verification" button →
SelfSdk.launch() - iOS test app: same button via SwiftUI wrapping KMP framework
- Test on emulator/simulator
- Configure
maven-publishfor AAR output - Configure XCFramework output + SPM
Package.swift - Validate: test app builds and launches on both platforms
Key Reference Files
| File | What to Look At |
|---|---|
app/android/.../RNPassportReaderModule.kt |
Android NFC implementation to port (PACE, BAC, DG reading, chip auth, passive auth) |
app/android/.../PassportNFC.kt |
Additional NFC utilities (if exists) |
app/ios/PassportReader.swift |
iOS NFC flow reference (MRZ key, readPassport call, SOD extraction) |
packages/kmp-shell/shared/ |
Previous KMP prototype (bridge protocol, handler pattern, MRZ utils — all reusable) |
packages/webview-bridge/src/types.ts |
Bridge protocol TypeScript types (must match Kotlin exactly) |
packages/mobile-sdk-alpha/src/types/public.ts |
Adapter interfaces (what the WebView expects the bridge to implement) |