From 1d0c6fe18716f5b15f073ee1bbad8bd4e44c7f5e Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:14:44 +0530 Subject: [PATCH] add kmp specs (#1902) --- specs/projects/sdk/OVERVIEW.md | 21 +- .../sdk/workstreams/kmp-revival/SPEC.md | 135 ++++ .../kmp-revival/plans/KR-01-android-parity.md | 758 ++++++++++++++++++ .../kmp-revival/plans/KR-02-ios-parity.md | 234 ++++++ .../plans/KR-03-validate-and-publish.md | 169 ++++ 5 files changed, 1311 insertions(+), 6 deletions(-) create mode 100644 specs/projects/sdk/workstreams/kmp-revival/SPEC.md create mode 100644 specs/projects/sdk/workstreams/kmp-revival/plans/KR-01-android-parity.md create mode 100644 specs/projects/sdk/workstreams/kmp-revival/plans/KR-02-ios-parity.md create mode 100644 specs/projects/sdk/workstreams/kmp-revival/plans/KR-03-validate-and-publish.md diff --git a/specs/projects/sdk/OVERVIEW.md b/specs/projects/sdk/OVERVIEW.md index cdbc0d947..7f449973a 100644 --- a/specs/projects/sdk/OVERVIEW.md +++ b/specs/projects/sdk/OVERVIEW.md @@ -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) diff --git a/specs/projects/sdk/workstreams/kmp-revival/SPEC.md b/specs/projects/sdk/workstreams/kmp-revival/SPEC.md new file mode 100644 index 000000000..95ce74984 --- /dev/null +++ b/specs/projects/sdk/workstreams/kmp-revival/SPEC.md @@ -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 | diff --git a/specs/projects/sdk/workstreams/kmp-revival/plans/KR-01-android-parity.md b/specs/projects/sdk/workstreams/kmp-revival/plans/KR-01-android-parity.md new file mode 100644 index 000000000..1c59cc4ae --- /dev/null +++ b/specs/projects/sdk/workstreams/kmp-revival/plans/KR-01-android-parity.md @@ -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, + ): 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): 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): 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): 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, + ): 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): 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): 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): 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): 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() + 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>?, + 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>? = 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, 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(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 + +~600–900 LOC changed. Within the 1k–3k 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. diff --git a/specs/projects/sdk/workstreams/kmp-revival/plans/KR-02-ios-parity.md b/specs/projects/sdk/workstreams/kmp-revival/plans/KR-02-ios-parity.md new file mode 100644 index 000000000..3d74ea927 --- /dev/null +++ b/specs/projects/sdk/workstreams/kmp-revival/plans/KR-02-ios-parity.md @@ -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 + +~200–300 LOC changed. Within the 1k–3k 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. diff --git a/specs/projects/sdk/workstreams/kmp-revival/plans/KR-03-validate-and-publish.md b/specs/projects/sdk/workstreams/kmp-revival/plans/KR-03-validate-and-publish.md new file mode 100644 index 000000000..60b4aa625 --- /dev/null +++ b/specs/projects/sdk/workstreams/kmp-revival/plans/KR-03-validate-and-publish.md @@ -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 1k–3k 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.