* add kotlin debug app * add specs * first kmp sdk version * add deploy script * save working nfc implementation * save demo app flow wip * agent feedback * show viewfinder on mrz * save working scan * add kotlin formatting * remove mrz overlay * fix expiry date * add feedback to mrz san * save improved nfc scanning * save wip * save gitignore and md state * add logging and error handling. get iOS demo app working * format * add swift formatting * enable iOS camera * save ios mrz implementation * nfc scanning works * final optimizations * add tests * fixes * better linting * agent feedback * bug fixes * formatting * agent feedback * fix app breaking on run * consolidate kotlin and swift clean up commands * fix pipeline by installing swiftlint * fix blurry scanning * fix ci --------- Co-authored-by: turnoffthiscomputer <colin.remi07@gmail.com>
13 KiB
Self Mobile SDK — Architecture & Implementation Spec
Why
MiniPay (Celo) needs to embed Self's identity verification in their KMP app. Today the wallet is a monolithic React Native app. We're rebuilding it as:
- A React WebView (UI layer) — published as npm packages
- A single KMP module (native layer) — hosts the WebView and provides NFC, biometrics, storage, camera, crypto via a bridge
MiniPay expects a single Kotlin Multiplatform interface that works on both iOS and Android.
Architecture Overview
┌──────────────────────────────────────────────┐
│ Host App (MiniPay, Self Wallet, etc.) │
│ ↓ calls SelfSdk.launch(request, callback) │
├──────────────────────────────────────────────┤
│ KMP SDK (single Kotlin module) │
│ shared/ │
│ commonMain/ Bridge protocol, MessageRouter│
│ SDK public API, data models │
│ androidMain/ WebView host (Android WebView)│
│ NFC (JMRTD), Biometrics, │
│ SecureStorage, Camera, Crypto │
│ iosMain/ WebView host (WKWebView) │
│ NFC (CoreNFC), Biometrics, │
│ SecureStorage, Camera, Crypto │
├──────────────────────────────────────────────┤
│ Bridge Layer (postMessage JSON protocol) │
├──────────────────────────────────────────────┤
│ WebView (bundled inside SDK artifact) │
│ @selfxyz/webview-bridge → npm (protocol) │
│ @selfxyz/webview-app → Vite bundle │
│ @selfxyz/mobile-sdk-alpha → core logic │
│ Vite build → single HTML + JS bundle │
└──────────────────────────────────────────────┘
Key principle: No separate android-sdk/ or ios-sdk/ modules. Everything is in shared/ using KMP expect/actual. MiniPay gets one dependency that works on both platforms.
Workstreams
| Person | Scope | Delivers |
|---|---|---|
| Person 1 | UI + WebView + Bridge JS | @selfxyz/webview-bridge (npm), @selfxyz/webview-app (Vite bundle) |
| Person 2 | KMP SDK + Native Handlers + Test App | packages/kmp-sdk/ → AAR + XCFramework, test app |
Each person has their own detailed spec:
- SPEC-PERSON1-UI.md — UI / WebView / Bridge JS
- SPEC-PERSON2-KMP.md — KMP SDK / Native Handlers
Shared Contract: Bridge Protocol
This is the interface both workstreams implement. It's the only coupling between them.
Message Format (JSON over postMessage)
// WebView → Native (request)
{
type: "request",
version: 1,
id: "uuid-v4", // correlation ID
domain: "nfc", // see domain list below
method: "scan", // method within domain
params: { ... }, // JSON-serializable payload
timestamp: 1234567890
}
// Native → WebView (response)
{
type: "response",
version: 1,
id: "uuid-v4",
domain: "nfc",
requestId: "uuid-of-request",
success: true,
data: { ... }, // result when success=true
error: null, // BridgeError when success=false
timestamp: 1234567890
}
// Native → WebView (unsolicited event)
{
type: "event",
version: 1,
id: "uuid-v4",
domain: "nfc",
event: "scanProgress",
data: { step: "reading_dg1", percent: 40 },
timestamp: 1234567890
}
Error Format
{ code: "NFC_NOT_SUPPORTED", message: "...", details?: { ... } }
Domain Catalog
| Domain | Methods | Events | Notes |
|---|---|---|---|
nfc |
scan, cancelScan, isSupported |
scanProgress, tagDiscovered, scanError |
120s timeout, progress streaming |
biometrics |
authenticate, isAvailable, getBiometryType |
— | Required for key access |
secureStorage |
get, set, remove |
— | Encrypted key-value store |
camera |
scanMRZ, isAvailable |
— | MRZ OCR from camera |
crypto |
sign, generateKey, getPublicKey |
— | hash() stays in WebView (Web Crypto API) |
haptic |
trigger |
— | Fire-and-forget |
analytics |
trackEvent, trackNfcEvent, logNfcEvent |
— | Fire-and-forget, no PII |
lifecycle |
ready, dismiss, setResult |
— | WebView → host app communication |
documents |
loadCatalog, saveCatalog, loadById, save, delete |
— | Encrypted document CRUD |
navigation |
goBack, goTo |
— | WebView-internal only (no bridge round-trip) |
NFC Scan Params (most complex domain)
{
passportNumber: string,
dateOfBirth: string, // YYMMDD
dateOfExpiry: string, // YYMMDD
canNumber?: string,
skipPACE?: boolean,
skipCA?: boolean,
extendedMode?: boolean,
usePacePolling?: boolean,
sessionId: string,
useCan?: boolean,
userId?: string
}
NFC Scan Result
{
passportData: {
mrz: string,
dsc: string, // PEM certificate
dg1Hash: number[],
dg2Hash: number[],
dgPresents: number[],
eContent: number[],
signedAttr: number[],
encryptedDigest: number[],
documentType: string, // "passport" | "id_card"
documentCategory: string,
parsed: boolean,
mock: boolean
}
}
Transport Mechanism
Android:
- WebView → Native:
addJavascriptInterface("SelfNativeAndroid")exposespostMessage(json)to JS - Native → WebView:
evaluateJavascript("window.SelfNativeBridge._handleResponse('...')")and_handleEvent('...')
iOS:
- WebView → Native:
WKScriptMessageHandlernamed"SelfNativeIOS"receivespostMessage(json) - Native → WebView:
evaluateJavaScript("window.SelfNativeBridge._handleResponse('...')")and_handleEvent('...')
JS side (injected at document start by native, or self-initializing in WebViewBridge class):
window.SelfNativeBridge = {
_pending: {}, // id → { resolve, reject, timeout }
_listeners: {}, // domain:event → [callback]
request(domain, method, params) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const msg = { type: "request", version: 1, id, domain, method, params, timestamp: Date.now() };
this._pending[id] = { resolve, reject, timeout: setTimeout(() => { ... }, 30000) };
// Android: SelfNativeAndroid.postMessage(JSON.stringify(msg))
// iOS: webkit.messageHandlers.SelfNativeIOS.postMessage(JSON.stringify(msg))
});
},
_handleResponse(json) { /* resolve/reject pending promise by requestId */ },
_handleEvent(json) { /* dispatch to listeners by domain:event */ },
on(domain, event, cb) { /* register listener */ },
off(domain, event, cb) { /* unregister listener */ },
};
Adapter Mapping
How mobile-sdk-alpha adapter interfaces map to bridge domains:
| SDK Adapter Interface | Bridge? | Bridge Domain.Method | Notes |
|---|---|---|---|
NFCScannerAdapter |
Yes | nfc.scan |
Core flow: scan passport NFC chip |
CryptoAdapter.hash() |
No | — | Web Crypto API in WebView |
CryptoAdapter.sign() |
Yes | crypto.sign |
Native secure enclave |
AuthAdapter |
Yes | secureStorage.get (with requireBiometric: true) |
Private key gated by biometrics |
DocumentsAdapter |
Yes | documents.* |
CRUD on encrypted passport data |
StorageAdapter |
Yes | secureStorage.* |
Key-value storage |
NavigationAdapter |
No | — | React Router (WebView-internal) |
NetworkAdapter |
No | — | fetch() works in WebView |
ClockAdapter |
No | — | Date.now() + setTimeout |
AnalyticsAdapter |
Yes | analytics.* |
Fire-and-forget |
LoggerAdapter |
No | — | Console in WebView |
How the Pieces Connect
Person 1 delivers: Person 2 delivers:
@selfxyz/webview-bridge (npm) KMP SDK (AAR + XCFramework)
@selfxyz/webview-app (Vite bundle) ├─ WebView host
↓ ├─ Native bridge handlers
↓ dist/index.html + bundle.js ├─ Asset bundling
↓ ├─ SelfSdk.launch() API
└────── bundled into ──────────────→ SDK artifact
Integration point: Person 2's Gradle/SPM build copies Person 1's Vite output (dist/) into the SDK's bundled assets. During development, Person 2 uses a mock HTML page or connects to Person 1's Vite dev server (http://10.0.2.2:5173).
Bridge contract: Both sides implement the same JSON protocol. Person 1 tests with MockNativeBridge (JS). Person 2 tests with a mock WebView that sends/receives bridge JSON.
Dependency Graph
Phase 1 (parallel — no inter-dependencies):
Chunk 1F (bridge package) ──→ Chunk 1E (app shell)
Chunk 2A (KMP setup + bridge) ──→ Chunks 2B, 2C, 2D, 2E
Phase 2 (parallel — after Phase 1):
Chunk 1B, 1C, 1D (UI screens) ──→ Chunk 1E (app shell)
Chunks 2B, 2C (Android) ──→ Chunk 2F (SDK API + test app)
Chunks 2D, 2E (iOS) ──→ Chunk 2F
Phase 3 (integration):
Chunk 1E (app shell output) ──→ Final integration
Chunk 2F (SDK API + test app) ──→ Final integration
Cleanup: What to Delete Before Starting
The previous prototype code should be deleted:
| Path | Reason |
|---|---|
packages/webview-bridge/ |
Will be recreated with same name but clean implementation |
packages/webview-app/ |
Will be recreated with proper architecture |
packages/kmp-shell/ |
Will be recreated as packages/kmp-sdk/ |
Keep: packages/mobile-sdk-alpha/ changes (Platform.OS removal, platform config).
Design Tokens (shared between Person 1 and Person 2)
Colors (from packages/mobile-sdk-alpha/src/constants/colors.ts)
| Token | Value | Usage |
|---|---|---|
black |
#000000 |
Primary text, buttons |
white |
#ffffff |
Backgrounds |
amber50 |
#FFFBEB |
Button text on dark bg |
slate50 |
#F8FAFC |
Page backgrounds |
slate300 |
#CBD5E1 |
Borders |
slate400 |
#94A3B8 |
Placeholder text |
slate500 |
#64748B |
Secondary text |
blue600 |
#2563EB |
Links, accents |
green500 / green600 |
#22C55E / #16A34A |
Success states |
red500 / red600 |
#EF4444 / #DC2626 |
Error states |
Fonts
| Token | Family | File |
|---|---|---|
advercase |
Advercase-Regular |
Advercase-Regular.otf |
dinot |
DINOT-Medium |
DINOT-Medium.otf |
dinotBold |
DINOT-Bold |
DINOT-Bold.otf |
plexMono |
IBMPlexMono-Regular |
IBMPlexMono-Regular.otf |
Font files are at app/web/fonts/.
Tamagui Config
Both app/tamagui.config.ts and packages/webview-app/tamagui.config.ts share the same configuration. Key: extends @tamagui/config/v3 with custom fonts (advercase, dinot, plexMono) using createFont() with shared size/lineHeight/letterSpacing scales.
Verification Plan
Person 1 validates:
# Build bridge package
cd packages/webview-bridge && npm run build && npx vitest run
# Build WebView app
cd packages/webview-app && npx tsc --noEmit && npx vite build
# Dev server for visual testing
cd packages/webview-app && npx vite dev # → http://localhost:5173
Person 2 validates:
# Compile shared module
cd packages/kmp-sdk && ./gradlew :shared:compileKotlinJvm
cd packages/kmp-sdk && ./gradlew :shared:jvmTest
# Compile Android
cd packages/kmp-sdk && ./gradlew :shared:compileDebugKotlinAndroid
# Compile iOS
cd packages/kmp-sdk && ./gradlew :shared:compileKotlinIosArm64
# Test app
cd packages/kmp-test-app && ./gradlew :androidApp:installDebug
Integration test:
- Person 1 runs
vite build→ producesdist/ - Person 2 copies
dist/into KMP test app assets - KMP test app launches WebView → loads
dist/index.html - Tap "Launch Verification" → WebView renders screens
- Bridge messages flow between JS and native (visible in console)
- NFC scan on physical device with real passport (final validation)
Key Reference Files
| File | What it Contains |
|---|---|
packages/mobile-sdk-alpha/src/types/public.ts |
All adapter interfaces (NFCScannerAdapter, CryptoAdapter, etc.) |
packages/mobile-sdk-alpha/src/constants/colors.ts |
Color tokens |
packages/mobile-sdk-alpha/src/constants/fonts.ts |
Font family names |
app/tamagui.config.ts |
Tamagui configuration (fonts, scales) |
app/web/fonts/ |
Font files (otf) |
app/android/.../RNPassportReaderModule.kt |
Android NFC implementation to port |
app/ios/PassportReader.swift |
iOS NFC implementation to reference |
app/src/screens/ |
Existing RN app screens (UI reference) |