add kmp specs (#1902)

This commit is contained in:
Seshanth.S
2026-04-02 02:14:44 +05:30
committed by GitHub
parent f4000c2532
commit 1d0c6fe187
5 changed files with 1311 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
# Self SDK — Overview
> Last updated: 2026-03-25
> Last updated: 2026-04-01
> Owner: Self Engineering
> Status: Active (WebView-first; current pass is mock-first UI migration)
@@ -53,9 +53,16 @@ On **March 25, 2026**, the active SDK execution changed again:
- [ ] `WV-06` KYC result flow through verification pipeline (future logic pass)
- [x] `BP-01` Build pipeline — bundle webview-app into native shells
### Active — KMP Revival
- [ ] `KR-01` Scope KMP Android to 3-domain native shell parity
- [ ] `KR-02` Scope KMP iOS to 3-domain native shell parity
- [ ] `KR-03` Validate build artifacts and test app
See [KMP Revival Spec](./workstreams/kmp-revival/SPEC.md) for details.
### Paused
- [x] KMP native shells retained for future reuse
- [x] Native MRZ/NFC consolidation work retained, but no longer on the critical path
- [x] RN native-shell packaging retained, but not part of current client delivery
- [x] MiniPay/KMP integration sample retained, but blocked by the paused KMP path
@@ -116,11 +123,12 @@ On **March 25, 2026**, the active SDK execution changed again:
| Android Shell | `packages/native-shell-android/` | Deferred | Future thin Kotlin shell: keychain/crypto + WebView host | Not required for current UI migration |
| iOS Shell | `packages/native-shell-ios/` | Deferred | Future thin Swift shell: keychain/crypto + WebView host | Not required for current UI migration |
| Test App | `packages/sdk-test-app/` | Deferred | Future native E2E harness | Not required for current UI migration |
| KMP Native Shell | `packages/kmp-sdk/` | Deprecated | Reference for native shell porting, replaced by native-shell-android/ios | Do not advance; use as port reference only |
| Swift Providers | `packages/self-sdk-swift/` | Deprecated | Reference for iOS keychain/crypto porting, replaced by native-shell-ios | Do not advance; use as port reference only |
| KMP Native Shell | `packages/kmp-sdk/` | Active | Native shell for KMP consumers — 3-domain scope (secureStorage, crypto, lifecycle) | KR-01 (Android), KR-02 (iOS), KR-03 (validate) |
| Swift Providers | `packages/self-sdk-swift/` | Active | iOS keychain/crypto provider implementations for KMP SDK | Required by KR-02 (query param support) |
| RN SDK | `packages/rn-sdk/` | Paused | Retained React Native shell work | Do not advance unless scope reopens |
| Native Consolidation | `app/ios/`, `packages/mobile-sdk-alpha/ios/`, related native code | Paused | Historical native cleanup and parity track | Keep as reference only for now |
| MiniPay Sample | `packages/kmp-minipay-sample/` | Paused | Historical KMP integration example | Resume only if KMP path returns |
| KMP Test App | `packages/kmp-sdk-test-app/` | Active | E2E test harness for KMP SDK | Scope to 3-domain in KR-03 |
| MiniPay Sample | `packages/kmp-minipay-sample/` | Paused | Historical KMP integration example | May resume now that KMP path is active |
## Scope Rules
@@ -151,4 +159,5 @@ On **March 25, 2026**, the active SDK execution changed again:
- **Future native shells (Kotlin + Swift):** [Native Shells Lite Spec](./workstreams/native-shells-lite/SPEC.md) (`NSL-01`, `NSL-02`, `NSL-03`)
- **Build pipeline:** [Build Pipeline Spec](./workstreams/build-pipeline/SPEC.md) (BP-01)
- **Shared engine follow-ups:** [SDK Core Spec](./workstreams/sdk-core/SPEC.md)
- **Retained KMP/RN work:** [Paused Work Index](./paused/INDEX.md)
- **KMP revival (3-domain scope):** [KMP Revival Spec](./workstreams/kmp-revival/SPEC.md)
- **Retained RN work:** [Paused Work Index](./paused/INDEX.md)

View File

@@ -0,0 +1,135 @@
# KMP Revival — Implementation Spec
> Last updated: 2026-04-01
> Owner: SDK / Platform
> Parent: `../../OVERVIEW.md`
> Status: Active
## Purpose
- You are reviving the KMP SDK (`packages/kmp-sdk/`) to provide a native shell option for consumers who already use Kotlin Multiplatform in their apps.
- Native-shells-lite (`packages/native-shell-android/`, `packages/native-shell-ios/`) remains the option for consumers using plain Kotlin (Android) or plain Swift (iOS).
- Both deliver the same 3-domain scope and satisfy the same bridge contract. The difference is the integration surface: KMP `expect`/`actual` with provider registry vs. standalone platform libraries.
- NSL-04 (delegate keychain to consumers) is already solved in KMP iOS via `SdkProviderRegistry`. KR-01 extends this to Android by moving provider interfaces to `commonMain` and shipping default Android implementations (`EncryptedSharedPreferencesProvider`, `AndroidKeystoreCryptoProvider`) that consumers can use out of the box or replace.
- Done when the KMP SDK builds, handles the 3 required bridge domains, produces publishable artifacts (AAR + XCFramework), and a test app exercises the full WebView flow.
## Why Offer a KMP Option
| Dimension | KMP SDK | Native Shells Lite |
|-----------|---------|-------------------|
| Target consumer | Apps using Kotlin Multiplatform | Pure Kotlin (Android) / pure Swift (iOS) apps |
| Provider pattern | Built-in via `SdkProviderRegistry` (both platforms) | NSL-04 adds it (~500-700 LOC) |
| Test coverage | ~10 test files | Zero tests (pending) |
| Publishing setup | `maven-publish` configured | None yet |
| Result types | Strongly-typed `VerificationResult` + `SelfSdkError` | Raw JSON string |
| Config model | Separated `SelfSdkConfig` + `VerificationRequest` | Flat config with 15+ params |
| Extra handlers | NFC, camera, biometrics available (not registered by default) | 3 domains only |
Both options are valid. KMP has more infrastructure already built; native-shells-lite is simpler for non-KMP consumers.
## Scope
- Scope KMP to the same 3 bridge domains as native-shells-lite: `secureStorage`, `crypto`, `lifecycle`
- Unify provider delegation across both platforms: move `SecureStorageProvider` and `CryptoProvider` interfaces to `commonMain`, add `SdkProviderRegistry` to `commonMain`, ship default Android implementations
- Strip NFC, camera, biometric handler registration and their dependencies (not needed for current delivery; retain code for future)
- Close the small gaps where native shells have features KMP lacks (WebChromeClient, query params, response shapes, protocol version validation)
- Validate build artifacts and test app
## Out of Scope
- NFC, camera, biometrics, haptic, analytics, documents, navigation handler registration (retain code but do not register)
- Changes to native-shells-lite (`packages/native-shell-android/`, `packages/native-shell-ios/`)
- WebView app changes (`packages/webview-app/`)
- Bridge protocol changes (`packages/webview-bridge/`)
- RN app changes (`app/`)
- `mobile-sdk-alpha` changes
## Invariants
- Bridge protocol v1 is the only coupling between native and WebView.
- Keychain/keystore is always native-managed. No web fallbacks for secure storage.
- Both platforms delegate secureStorage and crypto to consumer-provided providers via `SdkProviderRegistry`. Android ships default implementations; consumers can replace them.
- Response JSON shapes must match what `webview-bridge/src/adapters/crypto.ts` and `storage.ts` expect.
- No regressions to existing KMP test suite (`./gradlew :shared:jvmTest`).
## Dependencies
| Depends On | Type | Status | Notes |
|---|---|---|---|
| `packages/webview-bridge/` | Upstream (bridge protocol) | Done | Defines message shapes and transport names |
| `packages/webview-app/` | Upstream (WebView bundle) | Active | KMP loads this bundle |
| Build pipeline (BP-01) | Downstream | Done | Copies webview-app dist into native assets |
## Backlog
| ID | Title | Status | Priority | Depends On | Plan | Est. LOC |
|---|---|---|---|---|---|---|
| KR-01 | Scope KMP Android to 3-domain parity with provider delegation | Ready | High | - | [plans/KR-01-android-parity.md](./plans/KR-01-android-parity.md) | ~600-900 |
| KR-02 | Scope KMP iOS to 3-domain native shell parity | Ready | High | - | [plans/KR-02-ios-parity.md](./plans/KR-02-ios-parity.md) | ~200-300 |
| KR-03 | Validate build artifacts and test app | Ready | Medium | KR-01, KR-02 | [plans/KR-03-validate-and-publish.md](./plans/KR-03-validate-and-publish.md) | ~200 |
Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
## Completion Checklist
- [ ] Provider interfaces (`SecureStorageProvider`, `CryptoProvider`) live in `commonMain`
- [ ] `SdkProviderRegistry` lives in `commonMain` with platform-scoped defaults
- [ ] Android ships default providers (`EncryptedSharedPreferencesProvider`, `AndroidKeystoreCryptoProvider`)
- [ ] KMP Android builds with 3-domain scope
- [ ] KMP iOS builds with 3-domain scope
- [ ] All existing KMP tests pass
- [ ] Test app exercises full WebView flow on both platforms
- [ ] AAR + XCFramework artifacts produce clean builds
- [ ] OVERVIEW.md module table updated (KMP: Active alongside native-shells-lite)
## Reference Implementations
| What | Source | Notes |
|---|---|---|
| Bridge protocol (message shapes) | `packages/webview-bridge/src/types.ts` | Canonical — native must match |
| Storage adapter expectations | `packages/webview-bridge/src/adapters/storage.ts:15-18` | `get()` returns `{ value: string \| null }` |
| Crypto adapter expectations | `packages/webview-bridge/src/adapters/crypto.ts` | Defines request params and response shapes |
| CryptoHandler (Android ref) | `packages/native-shell-android/.../handlers/CryptoHandler.kt` | Reference for default `AndroidKeystoreCryptoProvider` — uses AndroidKeyStore, secp256r1, SHA256withECDSA |
| SecureStorageHandler (Android ref) | `packages/native-shell-android/.../handlers/SecureStorageHandler.kt` | Reference for provider-delegated handler — wraps `get()` in `{ value: ... }` |
| WebChromeClient | `packages/native-shell-android/.../webview/AndroidWebViewHost.kt:109-173` | Port permission + file upload handling |
| iOS provider registry | `packages/kmp-sdk/.../iosMain/.../providers/SdkProviderRegistry.kt` | Current iOS-only registry — move to commonMain |
| iOS CryptoBridgeHandler | `packages/kmp-sdk/.../iosMain/.../handlers/CryptoBridgeHandler.kt` | Provider-delegated crypto — reuse pattern for both platforms |
## Bridge Domain Contract
Only 3 domains are registered by the scoped KMP:
### `secureStorage`
| Method | Params | Response |
|---|---|---|
| `get` | `{ key: string }` | `{ value: string \| null }` |
| `set` | `{ key: string, value: string }` | `null` |
| `remove` | `{ key: string }` | `null` |
### `crypto`
| Method | Params | Response |
|---|---|---|
| `generateKey` | `{ keyRef: string }` | `{ keyRef: string, success: true }` |
| `getPublicKey` | `{ keyRef: string }` | `{ publicKey: string }` (base64) |
| `sign` | `{ data: string, keyRef: string }` (data is base64) | `{ signature: string }` (base64) |
### `lifecycle`
| Method | Params | Response |
|---|---|---|
| `ready` | `{}` | `null` (no-op) |
| `dismiss` | `{ reason?: string }` | `null` (finishes Activity / dismisses VC) |
| `setResult` | `{ success: bool, userId?, verificationId?, error? }` | `null` (forwards to host, then finishes) |
Any other domain request returns a `DOMAIN_NOT_FOUND` error response.
## Related Specs
| Spec | Relationship |
|---|---|
| [SDK Overview](../../OVERVIEW.md) | Parent architecture |
| [Native Shells Lite](../native-shells-lite/SPEC.md) | Sibling — serves non-KMP consumers |
| [Paused Native Shells (KMP)](../../paused/native-shells/SPEC.md) | Historical KMP work — validated foundation |
| [Build Pipeline](../build-pipeline/SPEC.md) | Downstream — bundles webview-app into native assets |

View File

@@ -0,0 +1,758 @@
## Scope KMP Android to 3-Domain Parity with Provider Delegation
> Last updated: 2026-04-01
> Status: Ready
- Workstream: kmp-revival
- Backlog IDs: KR-01
- Owner: TBD
- Branch: TBD
- PR: TBD
### Why
The KMP SDK Android target registers 5 handlers (NFC, Camera, Biometric, SecureStorage, Lifecycle) but is missing the `crypto` handler that native-shells-lite provides. The Android SecureStorage handler hardcodes `EncryptedSharedPreferences` instead of delegating to a consumer-provided provider (the iOS target already delegates via `SdkProviderRegistry`). There are also gaps in WebView capabilities (no WebChromeClient, no query params), an incorrect SecureStorage response shape, and no bridge protocol version validation.
You are closing these gaps by:
1. Moving provider interfaces to `commonMain` so both platforms share the same contract
2. Making Android handlers delegate to providers (matching iOS pattern)
3. Shipping default Android provider implementations consumers can use or replace
4. Bringing WebView host to parity with native-shell-android
### Scope
- `packages/kmp-sdk/` only (primarily Android target + commonMain provider interfaces)
- 3 bridge domains: `secureStorage`, `crypto`, `lifecycle`
- Provider delegation: move interfaces to commonMain, add default Android implementations
- WebView host upgrades (WebChromeClient, query params)
- Bridge alignment (response shapes, protocol version)
### Out of Scope
- iOS target handler changes (see KR-02) — but iOS will benefit from the commonMain interface move
- `packages/native-shell-android/` — do not modify
- `packages/webview-bridge/` — bridge protocol unchanged
- `packages/webview-app/` — WebView code unchanged
- NFC, Camera, Biometric handler code — retain files but do not register
- Publishing (see KR-03)
### Implementation Steps
#### 1. Move provider interfaces to commonMain
Currently `SecureStorageProvider` and `CryptoProvider` live in `iosMain/kotlin/xyz/self/sdk/providers/`. Move them to `commonMain` so both platforms share the same contract.
**Move:**
- `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/SecureStorageProvider.kt``packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/SecureStorageProvider.kt`
- `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/CryptoProvider.kt``packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/CryptoProvider.kt`
The interfaces remain identical — same package (`xyz.self.sdk.providers`), same methods. The iOS handlers that reference them will compile without changes since `commonMain` is visible to `iosMain`.
**Current `SecureStorageProvider` interface (iosMain):**
```kotlin
interface SecureStorageProvider {
fun get(key: String): String?
fun set(key: String, value: String)
fun remove(key: String)
fun clear()
}
```
**Current `CryptoProvider` interface (iosMain):**
```kotlin
interface CryptoProvider {
fun generateKey(keyRef: String)
fun getPublicKey(keyRef: String): String?
fun sign(keyRef: String, data: String): String?
fun deleteKey(keyRef: String)
}
```
No changes to the interfaces themselves — just the source set location.
#### 2. Move SdkProviderRegistry to commonMain
**Current file:** `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt`
**Current state:** iOS-only, requires all 8 providers (biometric, secureStorage, haptic, crypto, documents, nfc, cameraMrz, webView) to be non-null for `isConfigured()` to return true.
**Move to:** `packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt`
**Rewrite for 3-domain scope:**
```kotlin
package xyz.self.sdk.providers
object SdkProviderRegistry {
var secureStorage: SecureStorageProvider? = null
var crypto: CryptoProvider? = null
// Optional providers — retained for future handler registration
var biometric: BiometricProvider? = null
var haptic: HapticProvider? = null
var documents: DocumentsProvider? = null
var nfc: NfcProvider? = null
var cameraMrz: CameraMrzProvider? = null
var webView: WebViewProvider? = null
/**
* Returns true if the required 3-domain providers are configured.
* Only secureStorage and crypto are required. WebView provider is
* platform-specific (Android uses Activity-hosted WebView, iOS delegates).
*/
fun isConfigured(): Boolean =
secureStorage != null && crypto != null
fun reset() {
secureStorage = null
crypto = null
biometric = null
haptic = null
documents = null
nfc = null
cameraMrz = null
webView = null
}
}
```
**Key changes:**
- `isConfigured()` now checks only the 2 required providers (secureStorage, crypto) instead of all 8
- Added `reset()` for clean teardown between sessions
- Optional providers retained for future use but not required
- `webView` is kept optional — Android hosts WebView via Activity, iOS delegates to consumer
**Note:** The other provider interfaces (`BiometricProvider`, `HapticProvider`, etc.) that live in `iosMain` should remain there for now. Only move what's needed for the 3-domain scope. If they cause compile errors after moving `SdkProviderRegistry`, create empty `expect`/`actual` stubs or use nullable types (they're already nullable).
#### 3. Create default Android provider implementations
**New file:** `packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/EncryptedSharedPreferencesProvider.kt`
Extract the storage logic from the current `SecureStorageBridgeHandler` into a standalone provider:
```kotlin
package xyz.self.sdk.providers
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Default Android SecureStorageProvider using EncryptedSharedPreferences
* backed by Android Keystore. Consumers can replace this with their own
* implementation via SdkProviderRegistry.secureStorage.
*/
class EncryptedSharedPreferencesProvider(context: Context) : SecureStorageProvider {
private val prefs: SharedPreferences
init {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs = EncryptedSharedPreferences.create(
context,
"self_sdk_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun get(key: String): String? = prefs.getString(key, null)
override fun set(key: String, value: String) { prefs.edit().putString(key, value).apply() }
override fun remove(key: String) { prefs.edit().remove(key).apply() }
override fun clear() { prefs.edit().clear().apply() }
}
```
**New file:** `packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/AndroidKeystoreCryptoProvider.kt`
Extract crypto logic from `native-shell-android/.../handlers/CryptoHandler.kt`:
```kotlin
package xyz.self.sdk.providers
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
/**
* Default Android CryptoProvider using AndroidKeyStore with secp256r1/SHA256withECDSA.
* Consumers can replace this with their own implementation via SdkProviderRegistry.crypto.
*/
class AndroidKeystoreCryptoProvider : CryptoProvider {
private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
override fun generateKey(keyRef: String) {
if (keyStore.containsAlias(keyRef)) {
keyStore.deleteEntry(keyRef)
}
val spec = KeyGenParameterSpec.Builder(
keyRef,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.build()
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").apply {
initialize(spec)
generateKeyPair()
}
}
override fun getPublicKey(keyRef: String): String? {
val cert = keyStore.getCertificate(keyRef) ?: return null
return Base64.encodeToString(cert.publicKey.encoded, Base64.NO_WRAP)
}
override fun sign(keyRef: String, data: String): String? {
val privateKey = keyStore.getKey(keyRef, null) ?: return null
val dataBytes = Base64.decode(data, Base64.DEFAULT)
val signature = Signature.getInstance("SHA256withECDSA").apply {
initSign(privateKey as java.security.PrivateKey)
update(dataBytes)
}.sign()
return Base64.encodeToString(signature, Base64.NO_WRAP)
}
override fun deleteKey(keyRef: String) {
if (keyStore.containsAlias(keyRef)) {
keyStore.deleteEntry(keyRef)
}
}
}
```
#### 4. Rewrite SecureStorageBridgeHandler to delegate to provider
**File:** `packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt`
Replace the current handler (which hardcodes `EncryptedSharedPreferences`) with a provider-delegated version. This also fixes the `get()` response shape bug (currently returns bare primitives, needs `{ value: ... }` wrapping).
```kotlin
package xyz.self.sdk.handlers
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import xyz.self.sdk.bridge.BridgeDomain
import xyz.self.sdk.bridge.BridgeHandler
import xyz.self.sdk.bridge.BridgeHandlerException
import xyz.self.sdk.providers.SdkProviderRegistry
class SecureStorageBridgeHandler : BridgeHandler {
override val domain = BridgeDomain.SECURE_STORAGE
override suspend fun handle(
method: String,
params: Map<String, JsonElement>,
): JsonElement? = when (method) {
"get" -> get(params)
"set" -> set(params)
"remove" -> remove(params)
"clear" -> clear()
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown secureStorage method: $method")
}
private fun get(params: Map<String, JsonElement>): JsonElement {
val provider = SdkProviderRegistry.secureStorage
?: throw BridgeHandlerException("NOT_CONFIGURED", "SecureStorage provider not configured")
val key = params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
val value = provider.get(key)
return buildJsonObject {
put("value", if (value != null) JsonPrimitive(value) else JsonNull)
}
}
private fun set(params: Map<String, JsonElement>): JsonElement? {
val provider = SdkProviderRegistry.secureStorage
?: throw BridgeHandlerException("NOT_CONFIGURED", "SecureStorage provider not configured")
val key = params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
val value = params["value"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_VALUE", "Value parameter required")
provider.set(key, value)
return null
}
private fun remove(params: Map<String, JsonElement>): JsonElement? {
val provider = SdkProviderRegistry.secureStorage
?: throw BridgeHandlerException("NOT_CONFIGURED", "SecureStorage provider not configured")
val key = params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
provider.remove(key)
return null
}
private fun clear(): JsonElement? {
val provider = SdkProviderRegistry.secureStorage
?: throw BridgeHandlerException("NOT_CONFIGURED", "SecureStorage provider not configured")
provider.clear()
return null
}
}
```
**Key changes from current:**
- No longer takes `Context` parameter (no direct EncryptedSharedPreferences)
- Delegates to `SdkProviderRegistry.secureStorage`
- `get()` returns `buildJsonObject { put("value", ...) }` instead of bare `JsonPrimitive`/`JsonNull`
- Throws `NOT_CONFIGURED` if provider not set (fail-closed)
#### 5. Add CryptoBridgeHandler for Android
**New file:** `packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/CryptoBridgeHandler.kt`
The `androidMain` handlers directory currently has NFC, Camera, Biometric, SecureStorage, and Lifecycle — but no crypto handler. The iOS target has `CryptoBridgeHandler.kt` in `iosMain/` which delegates to `SdkProviderRegistry.crypto`. Create the same for Android.
Since both platforms now share `SdkProviderRegistry` in `commonMain`, the Android CryptoBridgeHandler can be **identical** to the iOS one. Consider moving it to `commonMain` as a shared handler, or keep platform-specific copies if the error handling differs.
**Recommended: move to commonMain** since the handler is purely provider-delegated with no platform imports:
**New file:** `packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/CryptoBridgeHandler.kt`
```kotlin
package xyz.self.sdk.handlers
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import xyz.self.sdk.bridge.BridgeDomain
import xyz.self.sdk.bridge.BridgeHandler
import xyz.self.sdk.bridge.BridgeHandlerException
import xyz.self.sdk.providers.SdkProviderRegistry
class CryptoBridgeHandler : BridgeHandler {
override val domain = BridgeDomain.CRYPTO
override suspend fun handle(
method: String,
params: Map<String, JsonElement>,
): JsonElement? = when (method) {
"generateKey" -> generateKey(params)
"getPublicKey" -> getPublicKey(params)
"sign" -> sign(params)
"deleteKey" -> deleteKey(params)
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown crypto method: $method")
}
private fun generateKey(params: Map<String, JsonElement>): JsonElement {
val provider = SdkProviderRegistry.crypto
?: throw BridgeHandlerException("NOT_CONFIGURED", "Crypto provider not configured")
val keyRef = params["keyRef"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
provider.generateKey(keyRef)
return buildJsonObject {
put("keyRef", JsonPrimitive(keyRef))
put("success", JsonPrimitive(true))
}
}
private fun getPublicKey(params: Map<String, JsonElement>): JsonElement {
val provider = SdkProviderRegistry.crypto
?: throw BridgeHandlerException("NOT_CONFIGURED", "Crypto provider not configured")
val keyRef = params["keyRef"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
val publicKey = provider.getPublicKey(keyRef)
?: throw BridgeHandlerException("KEY_NOT_FOUND", "Key not found: $keyRef")
return buildJsonObject {
put("publicKey", JsonPrimitive(publicKey))
}
}
private fun sign(params: Map<String, JsonElement>): JsonElement {
val provider = SdkProviderRegistry.crypto
?: throw BridgeHandlerException("NOT_CONFIGURED", "Crypto provider not configured")
val keyRef = params["keyRef"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
val data = params["data"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "data parameter required")
val signature = provider.sign(keyRef, data)
?: throw BridgeHandlerException("SIGN_FAILED", "Signing failed for key: $keyRef")
return buildJsonObject {
put("signature", JsonPrimitive(signature))
}
}
private fun deleteKey(params: Map<String, JsonElement>): JsonElement? {
val provider = SdkProviderRegistry.crypto
?: throw BridgeHandlerException("NOT_CONFIGURED", "Crypto provider not configured")
val keyRef = params["keyRef"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_PARAM", "keyRef parameter required")
provider.deleteKey(keyRef)
return null
}
}
```
If moved to `commonMain`, **delete** the existing `iosMain/.../handlers/CryptoBridgeHandler.kt` to avoid duplicate class definitions.
Response shapes match `webview-bridge/src/adapters/crypto.ts`:
- `generateKey``{ keyRef: string, success: true }`
- `getPublicKey``{ publicKey: string }` (base64)
- `sign``{ signature: string }` (base64)
#### 6. Scope handler registration to 3 domains
**File:** `packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt`
**Current `registerHandlers()` (lines 88-103):**
```kotlin
private fun registerHandlers() {
router.register(NfcBridgeHandler(this, router))
router.register(CameraMrzBridgeHandler(this))
router.register(BiometricBridgeHandler(this))
router.register(SecureStorageBridgeHandler(this))
router.register(LifecycleBridgeHandler(this))
}
```
**Change to:**
```kotlin
private fun registerHandlers() {
router.register(SecureStorageBridgeHandler()) // no Context — delegates to provider
router.register(CryptoBridgeHandler()) // delegates to provider
router.register(LifecycleBridgeHandler(this))
}
```
**Also:**
- Remove `requiredPermissions` array, `permissionLauncher`, and the permission-check block in `onCreate()` (lines 29-56). The 3-domain scope does not need CAMERA or NFC permissions at startup. The WebChromeClient (step 8) handles camera permissions on-demand.
- Simplify `onCreate()` to call `initVerificationFlow()` directly.
- Add provider initialization before handler registration:
```kotlin
private fun initVerificationFlow() {
// Register default providers if consumer hasn't set custom ones
if (SdkProviderRegistry.secureStorage == null) {
SdkProviderRegistry.secureStorage = EncryptedSharedPreferencesProvider(this)
}
if (SdkProviderRegistry.crypto == null) {
SdkProviderRegistry.crypto = AndroidKeystoreCryptoProvider()
}
// ... router setup, handler registration, WebView creation
}
```
This gives consumers the option to set their own providers before launching the Activity (via `SdkProviderRegistry.secureStorage = MyProvider()`), or get sensible defaults.
- Remove unused imports: `BiometricBridgeHandler`, `CameraMrzBridgeHandler`, `NfcBridgeHandler`, `Manifest`, `PackageManager`, `ActivityResultContracts`, `ContextCompat`.
- Add imports: `CryptoBridgeHandler`, `SdkProviderRegistry`, `EncryptedSharedPreferencesProvider`, `AndroidKeystoreCryptoProvider`.
#### 7. Add query param support to WebView URL loading
**File:** `packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt`
**Current `createWebView()` signature (line 37):** `fun createWebView(): WebView`
**Change to:** `fun createWebView(queryParams: String = ""): WebView`
**Update loadUrl calls (lines 129-139):**
```kotlin
// Before
webView.loadUrl("https://appassets.androidplatform.net/index.html")
// After
val baseUrl = "https://appassets.androidplatform.net/index.html"
val url = if (queryParams.isNotEmpty()) "$baseUrl?$queryParams" else baseUrl
webView.loadUrl(url)
```
Apply the same pattern to the debug URL (`http://127.0.0.1:5173`).
**Update caller in SelfVerificationActivity:** Build query params from intent extras (or `VerificationRequest` if available) and pass to `createWebView(queryParams)`. Reference `packages/native-shell-android/.../SelfVerificationActivity.kt:64-84` for the query string builder pattern using `buildString { }` with `Uri.encode()`.
The native-shell-android extracts 14 intent extras and encodes them. KMP currently only extracts `EXTRA_DEBUG_MODE`, `EXTRA_VERIFICATION_REQUEST`, and `EXTRA_CONFIG`. You need to either:
- Parse `EXTRA_VERIFICATION_REQUEST` JSON and build query params from it, or
- Add the same 14 intent extras as native-shell-android
The first approach is cleaner since KMP already has structured `VerificationRequest` types.
#### 8. Add WebChromeClient to AndroidWebViewHost
**File:** `packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt`
**Current (line 122):** After the `webViewClient` block, there is no `webChromeClient`.
**Port from:** `packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt:109-173`
Add after the existing `webViewClient` block (after line 122):
```kotlin
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest?) {
request ?: return
val origin = request.origin?.toString() ?: ""
val isTrusted = origin.startsWith("https://appassets.androidplatform.net") ||
(isDebugMode && origin.startsWith("http://127.0.0.1"))
if (!isTrusted) {
request.deny()
return
}
val activity = context as? Activity ?: run {
request.deny()
return
}
val neededPermissions = mutableListOf<String>()
if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
neededPermissions.add(Manifest.permission.CAMERA)
}
if (request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
neededPermissions.add(Manifest.permission.RECORD_AUDIO)
}
val missingPermissions = neededPermissions.filter {
ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED
}
if (missingPermissions.isNotEmpty()) {
pendingPermissionRequest = request
ActivityCompat.requestPermissions(
activity, missingPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST_CODE
)
return
}
request.grant(request.resources)
}
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?,
): Boolean {
fileUploadCallback?.onReceiveValue(null)
fileUploadCallback = filePathCallback
val intent = fileChooserParams?.createIntent() ?: return false
val activity = context as? Activity ?: run {
fileUploadCallback = null
return false
}
try {
activity.startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE)
} catch (e: Exception) {
fileUploadCallback = null
return false
}
return true
}
}
```
Add class-level fields:
```kotlin
var pendingPermissionRequest: PermissionRequest? = null
var fileUploadCallback: ValueCallback<Array<Uri>>? = null
```
Add companion object constants:
```kotlin
companion object {
const val FILE_CHOOSER_REQUEST_CODE = 1001
const val CAMERA_PERMISSION_REQUEST_CODE = 1002
}
```
**Also add permission result handling** to `SelfVerificationActivity`:
```kotlin
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == AndroidWebViewHost.CAMERA_PERMISSION_REQUEST_CODE) {
val pending = webViewHost.pendingPermissionRequest ?: return
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
pending.grant(pending.resources)
} else {
pending.deny()
}
webViewHost.pendingPermissionRequest = null
}
}
@Deprecated("Use Activity Result API")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == AndroidWebViewHost.FILE_CHOOSER_REQUEST_CODE) {
val results = if (resultCode == RESULT_OK && data != null) {
WebChromeClient.FileChooserParams.parseResult(resultCode, data)
} else null
webViewHost.fileUploadCallback?.onReceiveValue(results)
webViewHost.fileUploadCallback = null
}
}
```
#### 9. Add protocol version validation to MessageRouter
**File:** `packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt`
**Current (lines 26-32):** After parsing the BridgeRequest, handler lookup proceeds immediately with no version check.
**Add after parsing (line 32, before handler lookup):**
```kotlin
val request = json.decodeFromString<BridgeRequest>(rawJson)
// Add this check
if (request.version != BRIDGE_PROTOCOL_VERSION) {
sendResponse(BridgeResponse(
id = generateUuid(),
domain = request.domain,
requestId = request.id,
success = false,
error = BridgeError("UNSUPPORTED_VERSION", "Expected protocol version $BRIDGE_PROTOCOL_VERSION, got ${request.version}"),
))
return
}
```
Add companion object constant (matching `webview-bridge/src/types.ts:152`):
```kotlin
companion object {
const val BRIDGE_PROTOCOL_VERSION = 1
fun escapeForJs(jsonStr: String): String { ... } // existing
}
```
#### 10. Trim unused dependencies from build.gradle.kts
**File:** `packages/kmp-sdk/shared/build.gradle.kts`
**Remove or comment out these androidMain dependencies:**
```kotlin
// NFC/Passport — not needed for 3-domain scope
// 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 — not needed for 3-domain scope
// implementation("androidx.biometric:biometric:1.2.0-alpha05")
// Camera/MRZ — not needed for 3-domain scope
// implementation("com.google.mlkit:text-recognition:16.0.1")
// implementation("androidx.camera:camera-camera2:1.4.1")
// implementation("androidx.camera:camera-lifecycle:1.4.1")
// implementation("androidx.camera:camera-view:1.4.1")
```
**Keep:**
- `androidx.webkit:webkit` — WebView
- `androidx.security:security-crypto` — Default EncryptedSharedPreferencesProvider
- `androidx.appcompat:appcompat` — Activity
- `androidx.activity:activity-ktx` — Activity Result API
- `androidx.lifecycle:lifecycle-runtime-ktx` — Lifecycle
- `kotlinx.serialization` — Bridge JSON
- `kotlinx.coroutines` — Async handlers
### Files Created
| File | Purpose |
|------|---------|
| `shared/src/commonMain/.../providers/SecureStorageProvider.kt` | Moved from iosMain — shared interface |
| `shared/src/commonMain/.../providers/CryptoProvider.kt` | Moved from iosMain — shared interface |
| `shared/src/commonMain/.../providers/SdkProviderRegistry.kt` | Moved from iosMain — unified registry, 3-domain `isConfigured()` |
| `shared/src/commonMain/.../handlers/CryptoBridgeHandler.kt` | Provider-delegated crypto handler (replaces iOS-only version) |
| `shared/src/androidMain/.../providers/EncryptedSharedPreferencesProvider.kt` | Default Android SecureStorageProvider |
| `shared/src/androidMain/.../providers/AndroidKeystoreCryptoProvider.kt` | Default Android CryptoProvider |
### Files Modified
| File | Change |
|------|--------|
| `shared/src/androidMain/.../handlers/SecureStorageBridgeHandler.kt` | Rewrite: delegate to provider, fix `get()` response shape |
| `shared/src/androidMain/.../webview/SelfVerificationActivity.kt` | Register 3 handlers, default provider init, remove permission requests, add query params, add permission/file callbacks |
| `shared/src/androidMain/.../webview/AndroidWebViewHost.kt` | Add WebChromeClient, query params, permission/file fields |
| `shared/src/commonMain/.../bridge/MessageRouter.kt` | Add protocol version validation |
| `shared/build.gradle.kts` | Remove NFC/camera/biometric dependencies |
### Files Deleted
| File | Reason |
|------|--------|
| `shared/src/iosMain/.../providers/SecureStorageProvider.kt` | Moved to commonMain |
| `shared/src/iosMain/.../providers/CryptoProvider.kt` | Moved to commonMain |
| `shared/src/iosMain/.../providers/SdkProviderRegistry.kt` | Moved to commonMain |
| `shared/src/iosMain/.../handlers/CryptoBridgeHandler.kt` | Replaced by commonMain version |
### Files NOT Modified
- `packages/native-shell-android/` — sibling, serves different consumers
- `packages/native-shell-ios/` — sibling, serves different consumers
- `packages/webview-bridge/` — bridge protocol unchanged
- `packages/webview-app/` — WebView code unchanged
- `packages/mobile-sdk-alpha/` — SDK core unchanged
- `app/` — RN app unaffected
- Existing NFC/Camera/Biometric handler source files in androidMain — retained, just not registered
- `packages/self-sdk-swift/` — iOS providers unchanged (KR-02 scope)
### Preconditions
- `packages/webview-app/` builds and `dist/` output exists (for asset bundling)
- `packages/webview-bridge/` bridge protocol types are stable
### Validation
```bash
# Build KMP Android
cd packages/kmp-sdk && ./gradlew :shared:assembleDebug
# Run existing tests (must not regress)
cd packages/kmp-sdk && ./gradlew :shared:jvmTest
# Verify AAR artifact
cd packages/kmp-sdk && ./gradlew :shared:assembleRelease
ls -la shared/build/outputs/aar/shared-release.aar
# Verify provider delegation
# Add unit test: SdkProviderRegistry.isConfigured() returns false when providers are null
# Add unit test: SdkProviderRegistry.isConfigured() returns true with secureStorage + crypto set
# Add unit test: SecureStorageBridgeHandler throws NOT_CONFIGURED when provider is null
# Add unit test: CryptoBridgeHandler throws NOT_CONFIGURED when provider is null
# Verify response shapes
# Add unit test: SecureStorageBridgeHandler.get() returns JsonObject with "value" key
# Add unit test: CryptoBridgeHandler.generateKey returns { keyRef, success: true }
# Add unit test: CryptoBridgeHandler.getPublicKey returns { publicKey } (base64 string)
# Add unit test: CryptoBridgeHandler.sign returns { signature } (base64 string)
# Verify version validation
# Add unit test: MessageRouter rejects requests with wrong protocol version
```
### Definition of Done
- [ ] `SecureStorageProvider` and `CryptoProvider` interfaces live in `commonMain`
- [ ] `SdkProviderRegistry` lives in `commonMain` with 3-domain `isConfigured()` check
- [ ] `EncryptedSharedPreferencesProvider` ships as default Android SecureStorageProvider
- [ ] `AndroidKeystoreCryptoProvider` ships as default Android CryptoProvider
- [ ] `SecureStorageBridgeHandler` delegates to provider, `get()` returns `{ value: string | null }`
- [ ] `CryptoBridgeHandler` lives in `commonMain`, delegates to provider, handles `generateKey`, `getPublicKey`, `sign`, `deleteKey`
- [ ] `SelfVerificationActivity` registers only 3 handlers, initializes default providers
- [ ] Camera/NFC permission requests removed from Activity startup
- [ ] `MessageRouter` validates protocol version and rejects mismatches
- [ ] `AndroidWebViewHost` has `WebChromeClient` with permission + file upload handling
- [ ] `AndroidWebViewHost.createWebView()` supports query params
- [ ] Activity handles `onRequestPermissionsResult` and `onActivityResult` for WebChromeClient
- [ ] NFC/camera/biometric dependencies removed from `build.gradle.kts`
- [ ] All existing KMP tests pass (`./gradlew :shared:jvmTest`)
- [ ] Android AAR builds cleanly (`./gradlew :shared:assembleRelease`)
### Estimated PR Size
~600900 LOC changed. Within the 1k3k target.
### Status Log
- 2026-03-31: Plan created.
- 2026-04-01: Rewritten with provider delegation approach. Provider interfaces move to commonMain, Android gets default implementations (EncryptedSharedPreferencesProvider, AndroidKeystoreCryptoProvider). CryptoBridgeHandler shared across platforms. Verified all file references against codebase.

View File

@@ -0,0 +1,234 @@
## Scope KMP iOS to 3-Domain Native Shell Parity
> Last updated: 2026-04-01
> Status: Ready
- Workstream: kmp-revival
- Backlog IDs: KR-02
- Owner: TBD
- Branch: TBD
- PR: TBD
### Why
After KR-01, provider interfaces and `SdkProviderRegistry` live in `commonMain`, and `CryptoBridgeHandler` is shared. The iOS target benefits from these moves automatically, but still needs work: the WebView provider doesn't pass query params, handler registration is still wide (all 9 handlers), `isConfigured()` no longer requires all 8 providers (fixed in KR-01), and the iOS `SecureStorageBridgeHandler` has the same bare-value `get()` response bug as Android had.
You are scoping KMP iOS to 3-domain parity with native-shell-ios.
### Scope
- `packages/kmp-sdk/` (iOS target: `iosMain`)
- `packages/self-sdk-swift/` (Swift provider implementations — query param support)
- 3 bridge domains: `secureStorage`, `crypto`, `lifecycle`
- Query param support for WebView URL loading
### Out of Scope
- Android target (done in KR-01)
- `packages/native-shell-ios/` — do not modify
- `packages/webview-bridge/` — bridge protocol unchanged
- `packages/webview-app/` — WebView code unchanged
- NFC, Camera, Biometric provider code in self-sdk-swift — retain but do not require registration
### Implementation Steps
#### 1. Fix SecureStorageBridgeHandler get() response shape (iOS)
**File:** `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt`
**Current (line 44):** Returns `if (value != null) JsonPrimitive(value) else JsonNull` — bare primitive, same bug as Android had pre-KR-01.
**Required:** The TypeScript adapter at `webview-bridge/src/adapters/storage.ts:16` does `result?.value ?? null` — expects `{ value: string | null }`.
**Change:**
```kotlin
// Before (line 44)
return if (value != null) JsonPrimitive(value) else JsonNull
// After
return buildJsonObject {
put("value", if (value != null) JsonPrimitive(value) else JsonNull)
}
```
Add import: `import kotlinx.serialization.json.buildJsonObject`
**Note:** After KR-01, the Android handler was fully rewritten to delegate via provider. The iOS handler already delegates via `SdkProviderRegistry.secureStorage` — only the response shape needs fixing.
**Alternative:** If both handlers are identical after this fix (same provider delegation, same response shape), consider moving `SecureStorageBridgeHandler` to `commonMain` to eliminate duplication. Check whether the iOS handler's `clear()` method is needed (Android handler has it, native-shell-android does not).
#### 2. Add query param support to WebViewProviderImpl
**File:** `packages/self-sdk-swift/Sources/SelfSdkSwift/WebViewProviderImpl.swift`
**Current:** `createWebView(onMessageReceived:isDebugMode:)` loads `Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "self-sdk-web")` without appending query params.
**Required:** native-shell-ios passes verification config as query params via `URLComponents`:
```swift
var components = URLComponents(url: fileURL, resolvingAgainstBaseURL: false)
components?.query = queryParams
```
**Change the method signature:**
```swift
// Before
@objc(createWebViewOnMessageReceived:isDebugMode:)
public func createWebView(onMessageReceived: @escaping (String) -> Void,
isDebugMode: Bool) -> UIView
// After
@objc(createWebViewOnMessageReceived:isDebugMode:queryParams:)
public func createWebView(onMessageReceived: @escaping (String) -> Void,
isDebugMode: Bool,
queryParams: String? = nil) -> UIView
```
**Update the URL loading logic** to append query params when provided:
```swift
if let htmlURL = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "self-sdk-web") {
var targetURL = htmlURL
if let params = queryParams, !params.isEmpty {
var components = URLComponents(url: htmlURL, resolvingAgainstBaseURL: false)
components?.query = params
targetURL = components?.url ?? htmlURL
}
wv.loadFileURL(targetURL, allowingReadAccessTo: htmlURL.deletingLastPathComponent())
}
```
Apply the same pattern to the debug URL (`http://localhost:5173`).
#### 3. Update WebViewProvider interface and IosWebViewHost
**File:** `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt`
**Current (lines 18-29):** `createWebView()` calls `provider.createWebView(onMessageReceived, isDebugMode)` with no query params.
**Update `WebViewProvider` interface** (in `iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt` or wherever it's defined) to add the query param:
```kotlin
interface WebViewProvider {
fun createWebView(
onMessageReceived: (String) -> Unit,
isDebugMode: Boolean,
queryParams: String? = null,
): UIView
}
```
**Update `IosWebViewHost`** to forward query params from the `VerificationRequest`:
```kotlin
fun createWebView(queryParams: String? = null): UIView {
val provider = SdkProviderRegistry.webView
?: throw IllegalStateException("WebView provider not configured")
return provider.createWebView(
onMessageReceived = { message -> router.onMessageReceived(message) },
isDebugMode = isDebugMode,
queryParams = queryParams,
)
}
```
#### 4. Scope handler registration to 3 domains
**File:** `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt`
**Current (lines 145-158) registers 9 handlers:**
```kotlin
router.register(BiometricBridgeHandler())
router.register(SecureStorageBridgeHandler())
router.register(CryptoBridgeHandler())
router.register(HapticBridgeHandler())
router.register(AnalyticsBridgeHandler())
router.register(lifecycleHandler)
router.register(DocumentsBridgeHandler())
router.register(CameraMrzBridgeHandler())
router.register(NfcBridgeHandler(router))
```
**Change to 3 handlers:**
```kotlin
router.register(SecureStorageBridgeHandler())
router.register(CryptoBridgeHandler()) // now from commonMain after KR-01
router.register(lifecycleHandler)
```
Remove unused handler imports.
#### 5. Handle missing optional providers gracefully
After KR-01, `SdkProviderRegistry.isConfigured()` only requires `secureStorage` and `crypto`. But the remaining iOS handler files (Biometric, Haptic, etc.) still reference their providers. Since we're not registering those handlers, this is safe — but verify:
- The remaining handler files in `iosMain/handlers/` (BiometricBridgeHandler, HapticBridgeHandler, AnalyticsBridgeHandler, DocumentsBridgeHandler, CameraMrzBridgeHandler, NfcBridgeHandler) should still compile even though their providers may be nil. Since they're not registered, they won't be invoked.
- If any of them are referenced at import time and cause initialization issues, add `@Suppress("unused")` or ensure they're only instantiated in the registration block.
#### 6. Build query params from VerificationRequest
**File:** `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt`
The launch flow receives a `VerificationRequest`. Build query params from it and pass to `IosWebViewHost.createWebView(queryParams)`.
Reference the native-shell-ios `SelfSdkConfig.toQueryParams()` pattern for the field list. The KMP already has structured types, so use those rather than raw string extras.
### Files Modified
| File | Change |
|------|--------|
| `shared/src/iosMain/.../handlers/SecureStorageBridgeHandler.kt` | Fix `get()` response shape to `{ value: ... }` |
| `packages/self-sdk-swift/.../WebViewProviderImpl.swift` | Add `queryParams` parameter, append to URL |
| `shared/src/iosMain/.../webview/IosWebViewHost.kt` | Forward query params to provider |
| `shared/src/iosMain/.../providers/WebViewProvider.kt` | Add `queryParams` to interface |
| `shared/src/iosMain/.../api/SelfSdk.ios.kt` | Register only 3 handlers, build and pass query params |
### Files NOT Modified
- `packages/native-shell-ios/` — sibling, serves different consumers
- `packages/webview-bridge/` — bridge protocol unchanged
- `packages/webview-app/` — WebView code unchanged
- Self-sdk-swift crypto/secureStorage providers — already match native-shell-ios functionality
- `commonMain` files — already updated in KR-01
### Preconditions
- KR-01 complete (provider interfaces, SdkProviderRegistry, CryptoBridgeHandler in commonMain, MessageRouter version check)
- `packages/webview-app/` builds and `dist/` output exists
### Validation
```bash
# Build KMP iOS framework
cd packages/kmp-sdk && ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
# Build XCFramework
cd packages/kmp-sdk && ./gradlew createXCFramework
# Build self-sdk-swift
cd packages/self-sdk-swift && swift build
# Run KMP common tests (includes shared handler tests)
cd packages/kmp-sdk && ./gradlew :shared:jvmTest
# Verify SecureStorage response shape
# Add unit test: iOS SecureStorageBridgeHandler.get() returns JsonObject with "value" key
```
### Definition of Done
- [ ] iOS `SecureStorageBridgeHandler.get()` returns `{ value: string | null }` matching TypeScript adapter
- [ ] `WebViewProviderImpl.createWebView()` accepts and appends query params
- [ ] `IosWebViewHost` forwards query params from verification request
- [ ] `WebViewProvider` interface includes `queryParams` parameter
- [ ] Only 3 handlers registered on iOS (SecureStorage, Crypto, Lifecycle)
- [ ] SDK does not crash if optional providers (NFC, Camera, etc.) are not registered
- [ ] Query params built from VerificationRequest and passed to WebView
- [ ] XCFramework builds cleanly
- [ ] self-sdk-swift builds cleanly
- [ ] All jvmTest tests pass
### Estimated PR Size
~200300 LOC changed. Within the 1k3k target.
### Status Log
- 2026-03-31: Plan created.
- 2026-04-01: Updated to reflect KR-01 provider delegation changes. CryptoBridgeHandler now comes from commonMain. SdkProviderRegistry isConfigured() already fixed. SecureStorage response shape bug confirmed on iOS. Added query param building from VerificationRequest.

View File

@@ -0,0 +1,169 @@
## Validate Build Artifacts and Test App
> Last updated: 2026-04-01
> Status: Ready
- Workstream: kmp-revival
- Backlog IDs: KR-03
- Owner: TBD
- Branch: TBD
- PR: TBD
### Why
After KR-01 (Android parity) and KR-02 (iOS parity), you need to validate that the scoped KMP SDK produces clean publishable artifacts and that the test app exercises the full 3-domain WebView flow on both platforms. This validates that a KMP consumer can actually integrate the SDK end-to-end.
### Scope
- `packages/kmp-sdk/` — build artifact validation
- `packages/kmp-sdk-test-app/` — test app adaptation for 3-domain scope
- `packages/self-sdk-swift/` — ensure it integrates cleanly with scoped KMP
### Out of Scope
- Actual publishing to external Maven/SPM registries (that's a follow-up, equivalent to paused NS-08)
- `packages/native-shell-android/` and `packages/native-shell-ios/` — do not modify
- CI/CD pipeline changes
### Implementation Steps
#### 1. Validate Android AAR build
```bash
cd packages/kmp-sdk
# Debug build
./gradlew :shared:assembleDebug
# Release build
./gradlew :shared:assembleRelease
# Verify artifact exists and is reasonable size
ls -la shared/build/outputs/aar/shared-release.aar
# Should be significantly smaller than before (NFC/camera/biometric deps removed)
# Local Maven publish
./gradlew :shared:publishToMavenLocal
ls ~/.m2/repository/xyz/self/sdk/shared/
```
#### 2. Validate iOS XCFramework build
```bash
cd packages/kmp-sdk
# Framework build
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
# XCFramework (requires both arm64 and simulator targets)
./gradlew createXCFramework
# Verify output
ls -la shared/build/XCFrameworks/
```
#### 3. Adapt kmp-sdk-test-app for 3-domain scope
**Location:** `packages/kmp-sdk-test-app/`
The test app currently exercises all KMP handlers including NFC and camera. Update it to only use the 3-domain scope.
**Android (`packages/kmp-sdk-test-app/androidApp/`):**
- Remove NFC and Camera permission requests from AndroidManifest.xml (if present)
- The app should rely on default providers (Activity auto-initializes `EncryptedSharedPreferencesProvider` and `AndroidKeystoreCryptoProvider` if not set). Alternatively, demonstrate explicit provider registration before launch:
```kotlin
SdkProviderRegistry.secureStorage = EncryptedSharedPreferencesProvider(context)
SdkProviderRegistry.crypto = AndroidKeystoreCryptoProvider()
```
- Verify the app launches the SDK, loads the WebView, and handles lifecycle callbacks
- Verify crypto operations work (generateKey, getPublicKey, sign)
- Verify secure storage operations work (get, set, remove)
**iOS (`packages/kmp-sdk-test-app/iosApp/`):**
- Register only required providers: `secureStorage`, `crypto`, `webView`
- Remove registration of NFC, Camera, Biometric, Haptic, Documents providers
- Verify the app launches the SDK, loads the WebView, and handles lifecycle callbacks
- Ensure `self-sdk-web/` assets are in Copy Bundle Resources
#### 4. Run full test suite
```bash
# Common tests (bridge, routing, serialization, lifecycle)
cd packages/kmp-sdk && ./gradlew :shared:jvmTest
# Verify no test failures from scoping changes
# Tests for NFC, Camera, MRZ should still pass (they test code, not registration)
```
#### 5. Update OVERVIEW.md module table
**File:** `specs/projects/sdk/OVERVIEW.md`
Update the module table (around line 110) to reflect KMP revival:
**Already done** — OVERVIEW.md module table was updated as part of the spec review on 2026-04-01. Verify it still reflects:
| Module | Status | Notes |
|--------|--------|-------|
| KMP Native Shell (`packages/kmp-sdk/`) | Active (3-domain scope) | Serves KMP consumers, provider delegation on both platforms |
| Swift Providers (`packages/self-sdk-swift/`) | Active | iOS providers for KMP |
| KMP Test App (`packages/kmp-sdk-test-app/`) | Active | E2E harness |
### Files Modified
| File | Change |
|------|--------|
| `packages/kmp-sdk-test-app/` (multiple files) | Scope to 3-domain handlers/providers |
| `specs/projects/sdk/OVERVIEW.md` | Update module table with KMP status |
### Files NOT Modified
- `packages/kmp-sdk/` — no code changes (KR-01 and KR-02 handle this)
- `packages/native-shell-*` — sibling implementations, unchanged
### Preconditions
- KR-01 complete (Android parity)
- KR-02 complete (iOS parity)
### Validation
```bash
# Full Android validation
cd packages/kmp-sdk && ./gradlew :shared:assembleRelease && ./gradlew :shared:jvmTest && ./gradlew :shared:publishToMavenLocal
# Full iOS validation
cd packages/kmp-sdk && ./gradlew createXCFramework
cd packages/self-sdk-swift && swift build
# Test app Android build
cd packages/kmp-sdk-test-app/androidApp && ../gradlew :androidApp:assembleDebug
# Test app iOS build (Xcode required)
# Open packages/kmp-sdk-test-app/iosApp/ in Xcode and build for simulator
```
### Definition of Done
- [ ] Android AAR builds (debug + release) with no errors
- [ ] iOS XCFramework builds with no errors
- [ ] Local Maven publish succeeds
- [ ] All jvmTest tests pass
- [ ] Test app Android builds and runs
- [ ] Test app iOS builds and runs
- [ ] Crypto operations work in test app (generateKey, sign, getPublicKey)
- [ ] SecureStorage operations work in test app (get, set, remove)
- [ ] Lifecycle operations work in test app (ready, dismiss, setResult)
- [ ] OVERVIEW.md module table updated
- [ ] AAR size is smaller than pre-scoping (NFC/camera deps removed)
- [ ] Provider delegation works end-to-end on Android (default providers)
- [ ] Provider delegation works end-to-end on iOS (self-sdk-swift providers)
### Estimated PR Size
~200 LOC changed. Within the 1k3k target.
### Status Log
- 2026-03-31: Plan created.
- 2026-04-01: Updated for provider delegation (KR-01 change). Added provider E2E validation. OVERVIEW.md already updated.