From 37b8f0ca71867dd5c0f5ec15aa110472e375a1d3 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:15:07 +0530 Subject: [PATCH] Scope KMP SDK to 3-domain parity (KR-01, KR-02, KR-03) (#1915) * KR-01: Scope KMP Android to 3-domain parity with provider delegation Move SecureStorageProvider, CryptoProvider, and CryptoBridgeHandler to commonMain so both platforms share the same contract. Add default Android providers (EncryptedSharedPreferencesProvider, AndroidKeystoreCryptoProvider) that consumers can replace via SdkProviderRegistry. - Rewrite Android SecureStorageBridgeHandler to delegate to provider and fix get() response shape to return { value: string | null } - Register only 3 handlers (secureStorage, crypto, lifecycle) in Activity - Add WebChromeClient with permission and file upload handling - Add query param support to WebView URL loading - Add bridge protocol version validation to MessageRouter - Remove NFC/camera/biometric dependencies from build.gradle.kts - Remove out-of-scope permissions from AndroidManifest.xml - Create IosProviderRegistry for iOS-specific provider fields Co-Authored-By: Claude Opus 4.6 * KR-02: Scope KMP iOS to 3-domain parity with query param support Move SecureStorageBridgeHandler to commonMain (fixes iOS get() response shape to return { value: string | null } matching the bridge adapter). Both Android and iOS now share the same handler via commonMain. - Register only 3 handlers on iOS (secureStorage, crypto, lifecycle) - Add queryParams parameter to WebViewProvider interface - Update IosWebViewHost to forward query params from VerificationRequest - Update WebViewProviderImpl.swift to append query params to URL - Relax isConfigured check to only require secureStorage + crypto + webView - Remove unused handler imports from SelfSdk.ios.kt Co-Authored-By: Claude Opus 4.6 * KR-03: Simplify test app to 3-domain smoke harness Gut the MRZ/NFC-first flow from the test app and replace with a focused 3-domain smoke test screen that validates secureStorage (set/get/remove round-trip), crypto (generateKey/getPublicKey/sign/deleteKey), and lifecycle (validated via SDK launch flow). - Add DomainSmokeScreen with pass/fail output per domain - Remove MRZ/NFC navigation routes and expect/actual screen declarations - Remove NFC/CAMERA permissions from Android manifest - Remove camera dependency from build.gradle.kts - Scope iOS test app to register only required providers (secureStorage, crypto, webView) Co-Authored-By: Claude Opus 4.6 * Fix issues - test app * coderabbit comments * fix ci * klint * coderabbit review comments * Enhance permission handling in AndroidWebViewHost * fix registry --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Justin Hernandez --- .../iosApp/iosApp/iOSApp.swift | 12 - .../src/androidMain/AndroidManifest.xml | 6 +- .../kotlin/xyz/self/testapp/App.android.kt | 45 ---- .../kotlin/xyz/self/testapp/MainActivity.kt | 12 + .../commonMain/kotlin/xyz/self/testapp/App.kt | 53 +---- .../self/testapp/screens/DomainSmokeScreen.kt | 219 ++++++++++++++++++ .../self/testapp/screens/SdkLaunchScreen.kt | 110 ++++++++- .../kotlin/xyz/self/testapp/App.ios.kt | 47 ---- .../self/testapp/screens/MrzScanScreen.ios.kt | 39 +--- .../self/testapp/screens/NfcScanScreen.ios.kt | 62 +---- .../iosApp/iosApp.xcodeproj/project.pbxproj | 22 +- .../xcshareddata/xcschemes/iosApp.xcscheme | 79 +++++++ .../kmp-sdk-test-app/iosApp/iosApp/Info.plist | 41 +--- .../iosApp/iosApp/iOSApp.swift | 24 +- .../iosApp/iosApp/iosApp.entitlements | 8 +- packages/kmp-sdk/shared/build.gradle.kts | 16 +- .../src/androidMain/AndroidManifest.xml | 23 +- .../handlers/SecureStorageBridgeHandler.kt | 120 ---------- .../AndroidKeystoreCryptoProvider.kt | 62 +++++ .../EncryptedSharedPreferencesProvider.kt | 49 ++++ .../self/sdk/webview/AndroidWebViewHost.kt | 204 ++++++++-------- .../sdk/webview/SelfVerificationActivity.kt | 190 ++++++++++----- .../kotlin/xyz/self/sdk/api/SelfSdkConfig.kt | 24 ++ .../xyz/self/sdk/api/VerificationRequest.kt | 6 + .../xyz/self/sdk/bridge/MessageRouter.kt | 23 +- .../self/sdk/handlers/CryptoBridgeHandler.kt | 0 .../handlers/SecureStorageBridgeHandler.kt | 12 +- .../xyz/self/sdk/providers/CryptoProvider.kt | 0 .../self/sdk/providers/SdkProviderRegistry.kt | 22 ++ .../sdk/providers/SecureStorageProvider.kt | 0 .../kotlin/xyz/self/sdk/api/SelfSdk.ios.kt | 69 ++++-- .../sdk/handlers/BiometricBridgeHandler.kt | 8 +- .../sdk/handlers/CameraMrzBridgeHandler.kt | 8 +- .../sdk/handlers/DocumentsBridgeHandler.kt | 12 +- .../self/sdk/handlers/HapticBridgeHandler.kt | 6 +- .../xyz/self/sdk/handlers/NfcBridgeHandler.kt | 8 +- ...iderRegistry.kt => IosProviderRegistry.kt} | 21 +- .../xyz/self/sdk/providers/WebViewProvider.kt | 1 + .../xyz/self/sdk/webview/IosWebViewHost.kt | 11 +- .../WebView/SelfWebViewHost.swift | 39 ++-- .../Providers/WebViewProviderImpl.swift | 89 +++++-- .../src/screens/debug/KeychainDebugScreen.tsx | 184 +++++++++------ 42 files changed, 1194 insertions(+), 792 deletions(-) delete mode 100644 packages/kmp-sdk-test-app/composeApp/src/androidMain/kotlin/xyz/self/testapp/App.android.kt create mode 100644 packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/DomainSmokeScreen.kt delete mode 100644 packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/App.ios.kt create mode 100644 packages/kmp-sdk-test-app/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme delete mode 100644 packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt create mode 100644 packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/AndroidKeystoreCryptoProvider.kt create mode 100644 packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/EncryptedSharedPreferencesProvider.kt rename packages/kmp-sdk/shared/src/{iosMain => commonMain}/kotlin/xyz/self/sdk/handlers/CryptoBridgeHandler.kt (100%) rename packages/kmp-sdk/shared/src/{iosMain => commonMain}/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt (94%) rename packages/kmp-sdk/shared/src/{iosMain => commonMain}/kotlin/xyz/self/sdk/providers/CryptoProvider.kt (100%) create mode 100644 packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt rename packages/kmp-sdk/shared/src/{iosMain => commonMain}/kotlin/xyz/self/sdk/providers/SecureStorageProvider.kt (100%) rename packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/{SdkProviderRegistry.kt => IosProviderRegistry.kt} (61%) diff --git a/packages/kmp-minipay-sample/iosApp/iosApp/iOSApp.swift b/packages/kmp-minipay-sample/iosApp/iosApp/iOSApp.swift index 6ed233e65..11cb8092c 100644 --- a/packages/kmp-minipay-sample/iosApp/iosApp/iOSApp.swift +++ b/packages/kmp-minipay-sample/iosApp/iosApp/iOSApp.swift @@ -10,28 +10,16 @@ import SelfSdkSwift // These bridge the SelfSdkSwift implementations to the Kotlin provider protocols // exported from the ComposeApp (KMP) framework. -extension BiometricProviderImpl: BiometricProvider {} extension SecureStorageProviderImpl: SecureStorageProvider {} -extension HapticProviderImpl: HapticProvider {} extension CryptoProviderImpl: CryptoProvider {} -extension DocumentsProviderImpl: DocumentsProvider {} -extension WebViewProviderImpl: WebViewProvider {} -extension NfcProviderImpl: NfcProvider {} -extension CameraMrzProviderImpl: CameraMrzProvider {} @main struct iOSApp: App { init() { // Register all Swift provider implementations with the KMP SdkProviderRegistry let registry = SdkProviderRegistry.shared - registry.biometric = BiometricProviderImpl() registry.secureStorage = SecureStorageProviderImpl() - registry.haptic = HapticProviderImpl() registry.crypto = CryptoProviderImpl() - registry.documents = DocumentsProviderImpl() - registry.webView = WebViewProviderImpl() - registry.nfc = NfcProviderImpl() - registry.cameraMrz = CameraMrzProviderImpl() } var body: some Scene { diff --git a/packages/kmp-sdk-test-app/composeApp/src/androidMain/AndroidManifest.xml b/packages/kmp-sdk-test-app/composeApp/src/androidMain/AndroidManifest.xml index e51831db5..21feda49f 100644 --- a/packages/kmp-sdk-test-app/composeApp/src/androidMain/AndroidManifest.xml +++ b/packages/kmp-sdk-test-app/composeApp/src/androidMain/AndroidManifest.xml @@ -1,11 +1,9 @@ - + - - - + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Tests secureStorage, crypto, and lifecycle providers directly.", + style = MaterialTheme.typography.bodyMedium, + ) + + Button( + onClick = { + running = true + storageResult = SmokeResult() + cryptoResult = SmokeResult() + scope.launch { + storageResult = runStorageSmoke() + cryptoResult = runCryptoSmoke() + running = false + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !running, + ) { + Text(if (running) "Running..." else "Run Smoke Tests") + } + + SmokeCard("secureStorage", storageResult) + SmokeCard("crypto", cryptoResult) + SmokeCard("lifecycle", SmokeResult(CheckStatus.PASS, "ready/setResult validated via SDK launch flow")) + + Text( + text = + "Lifecycle is validated end-to-end via the SDK Launch screen — " + + "the WebView calls ready on load and setResult on completion.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun SmokeCard( + domain: String, + result: SmokeResult, +) { + val (containerColor, icon, tint) = + when (result.status) { + CheckStatus.PASS -> + Triple( + MaterialTheme.colorScheme.primaryContainer, + Icons.Default.CheckCircle, + Color(0xFF4CAF50), + ) + CheckStatus.FAIL -> + Triple( + MaterialTheme.colorScheme.errorContainer, + Icons.Default.Close, + MaterialTheme.colorScheme.error, + ) + CheckStatus.PENDING -> + Triple( + MaterialTheme.colorScheme.surfaceVariant, + Icons.Default.Refresh, + MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Card( + colors = CardDefaults.cardColors(containerColor = containerColor), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = tint, + ) + Column { + Text(domain, style = MaterialTheme.typography.titleSmall) + if (result.detail.isNotEmpty()) { + Text( + text = result.detail, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + } + } +} + +private fun runStorageSmoke(): SmokeResult { + val provider = + SdkProviderRegistry.secureStorage + ?: return SmokeResult(CheckStatus.FAIL, "Provider not configured") + return try { + val key = "smoke_test_key" + val value = "smoke_test_value_${kotlin.random.Random.nextInt(100000)}" + + provider.set(key, value) + val read = provider.get(key) + if (read != value) { + return SmokeResult(CheckStatus.FAIL, "Read mismatch: expected=$value got=$read") + } + + provider.remove(key) + val afterRemove = provider.get(key) + if (afterRemove != null) { + return SmokeResult(CheckStatus.FAIL, "Remove failed: got=$afterRemove after remove") + } + + SmokeResult(CheckStatus.PASS, "set/get/remove round-trip OK") + } catch (e: Exception) { + SmokeResult(CheckStatus.FAIL, "Exception: ${e.message}") + } +} + +private fun runCryptoSmoke(): SmokeResult { + val provider = + SdkProviderRegistry.crypto + ?: return SmokeResult(CheckStatus.FAIL, "Provider not configured") + return try { + val keyRef = "smoke_test_key_${kotlin.random.Random.nextInt(100000)}" + + provider.generateKey(keyRef) + + val publicKey = provider.getPublicKey(keyRef) + if (publicKey.isNullOrEmpty()) { + return SmokeResult(CheckStatus.FAIL, "getPublicKey returned null/empty") + } + + val testData = "dGVzdA==" // base64("test") + val signature = provider.sign(keyRef, testData) + if (signature.isNullOrEmpty()) { + return SmokeResult(CheckStatus.FAIL, "sign returned null/empty") + } + + provider.deleteKey(keyRef) + + SmokeResult( + CheckStatus.PASS, + "generateKey/getPublicKey/sign/deleteKey OK\npubKey=${publicKey.take(20)}...\nsig=${signature.take(20)}...", + ) + } catch (e: Exception) { + SmokeResult(CheckStatus.FAIL, "Exception: ${e.message}") + } +} diff --git a/packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt b/packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt index 5c65f20a9..0f0af5894 100644 --- a/packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt +++ b/packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt @@ -4,16 +4,20 @@ package xyz.self.testapp.screens +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -36,6 +40,7 @@ import androidx.navigation.NavController import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import xyz.self.sdk.api.SelfEnvironment import xyz.self.sdk.api.SelfSdk import xyz.self.sdk.api.SelfSdkCallback import xyz.self.sdk.api.SelfSdkConfig @@ -46,14 +51,31 @@ import xyz.self.sdk.api.VerificationResult @OptIn(ExperimentalMaterial3Api::class) @Composable fun SdkLaunchScreen(navController: NavController) { - var userId by remember { mutableStateOf("test-user") } + var useMockDocument by remember { mutableStateOf(true) } + var userId by remember { mutableStateOf("d6e7f8a9-1b2c-4d3e-a5f6-789012345678") } var scope by remember { mutableStateOf("identity") } + var verificationId by remember { mutableStateOf("example-verification-id") } + var disclosures by remember { mutableStateOf("full_name,dob") } + var appName by remember { mutableStateOf("Self Test App") } + var appEndpoint by remember { mutableStateOf("") } + var resultType by remember { mutableStateOf("") } var callbackStatus by remember { mutableStateOf("Idle") } var callbackPayload by remember { mutableStateOf(null) } var callbackError by remember { mutableStateOf(null) } + val environment = if (useMockDocument) SelfEnvironment.STG else SelfEnvironment.PROD val coroutineScope = rememberCoroutineScope() - val sdk = remember { SelfSdk.configure(SelfSdkConfig(debug = true)) } + val sdk = + remember(environment, appName, appEndpoint) { + SelfSdk.configure( + SelfSdkConfig( + environment = environment, + debug = true, + appName = appName.ifBlank { null }, + appEndpoint = appEndpoint.ifBlank { null }, + ), + ) + } val json = remember { Json { prettyPrint = true } } Scaffold( @@ -73,6 +95,42 @@ fun SdkLaunchScreen(navController: NavController) { style = MaterialTheme.typography.bodyMedium, ) + Text("Document Mode", style = MaterialTheme.typography.labelMedium) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + val shape = RoundedCornerShape(8.dp) + Button( + onClick = { useMockDocument = true }, + modifier = Modifier.weight(1f), + shape = shape, + colors = + if (useMockDocument) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.outlinedButtonColors() + }, + border = if (!useMockDocument) BorderStroke(1.dp, MaterialTheme.colorScheme.outline) else null, + ) { + Text("Mock Document") + } + Button( + onClick = { useMockDocument = false }, + modifier = Modifier.weight(1f), + shape = shape, + colors = + if (!useMockDocument) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.outlinedButtonColors() + }, + border = if (useMockDocument) BorderStroke(1.dp, MaterialTheme.colorScheme.outline) else null, + ) { + Text("Real Document") + } + } + OutlinedTextField( value = userId, onValueChange = { userId = it }, @@ -89,6 +147,46 @@ fun SdkLaunchScreen(navController: NavController) { singleLine = true, ) + OutlinedTextField( + value = verificationId, + onValueChange = { verificationId = it }, + label = { Text("Verification ID") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = disclosures, + onValueChange = { disclosures = it }, + label = { Text("Disclosures (comma-separated)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = appName, + onValueChange = { appName = it }, + label = { Text("App Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = appEndpoint, + onValueChange = { appEndpoint = it }, + label = { Text("App Endpoint") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = resultType, + onValueChange = { resultType = it }, + label = { Text("Result Type") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Button( onClick = { callbackStatus = "Launching verification..." @@ -99,7 +197,9 @@ fun SdkLaunchScreen(navController: NavController) { VerificationRequest( userId = userId.ifBlank { null }, scope = scope.ifBlank { null }, - disclosures = listOf("name", "nationality", "date_of_birth"), + verificationId = verificationId.ifBlank { null }, + disclosures = disclosures.split(",").map { it.trim() }.filter { it.isNotEmpty() }, + resultType = resultType.ifBlank { null }, ) sdk.launch( @@ -142,10 +242,10 @@ fun SdkLaunchScreen(navController: NavController) { } OutlinedButton( - onClick = { navController.navigate("passport_details") }, + onClick = { navController.navigate("domain_smoke") }, modifier = Modifier.fillMaxWidth(), ) { - Text("Open Manual MRZ/NFC Flow") + Text("Run Domain Smoke Tests") } Spacer(modifier = Modifier.height(8.dp)) diff --git a/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/App.ios.kt b/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/App.ios.kt deleted file mode 100644 index 5d3562981..000000000 --- a/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/App.ios.kt +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. -// SPDX-License-Identifier: BUSL-1.1 -// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. - -package xyz.self.testapp - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.navigation.NavController -import xyz.self.testapp.viewmodels.VerificationViewModel - -/** - * iOS implementation: Forward to the actual MRZ scan screen implementation - */ -@Composable -actual fun MrzScanScreen( - navController: NavController, - viewModel: VerificationViewModel, -) { - xyz.self.testapp.screens - .MrzScanScreen(navController, viewModel) -} - -/** - * iOS implementation: Use the shared commonMain implementation - */ -@Composable -actual fun MrzConfirmationScreen( - navController: NavController, - viewModel: VerificationViewModel, -) { - xyz.self.testapp.screens - .MrzConfirmationScreen(navController, viewModel) -} - -/** - * iOS implementation: Forward to the actual NFC screen implementation - */ -@Composable -actual fun NfcScanScreen( - navController: NavController, - viewModel: VerificationViewModel, -) { - xyz.self.testapp.screens - .NfcScanScreen(navController, viewModel) -} diff --git a/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/MrzScanScreen.ios.kt b/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/MrzScanScreen.ios.kt index 3ac5e505e..210ad8bb7 100644 --- a/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/MrzScanScreen.ios.kt +++ b/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/MrzScanScreen.ios.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -38,7 +37,6 @@ import platform.UIKit.UIApplicationOpenSettingsURLString import platform.UIKit.UIColor import platform.UIKit.UIView import xyz.self.sdk.models.MrzDetectionState -import xyz.self.sdk.providers.SdkProviderRegistry import xyz.self.testapp.components.MrzViewfinder import xyz.self.testapp.models.PassportData import xyz.self.testapp.models.VerificationFlowState @@ -388,7 +386,8 @@ private suspend fun requestCameraPermission(): Boolean = /** * Creates a native camera preview view with MRZ detection - * Uses SdkProviderRegistry.cameraMrz provider registered by SelfSdkSwift.configure() + * NOTE: Temporarily disabled — cameraMrz provider moved out of 3-domain scope. + * Will be re-enabled when MRZ/NFC providers are added back. */ @OptIn(ExperimentalForeignApi::class) private fun createCameraPreview( @@ -396,38 +395,6 @@ private fun createCameraPreview( onProgress: (MrzDetectionState) -> Unit, onError: (String) -> Unit, ): UIView { - val provider = SdkProviderRegistry.cameraMrz - - if (provider != null) { - return provider.createCameraView( - onMrzDetected = { jsonString -> - try { - val jsonElement = Json.parseToJsonElement(jsonString) - onMrzDetected(jsonElement) - } catch (e: Exception) { - Logger.e("MrzScan", "Failed to parse JSON from Swift", e) - onError("Failed to parse scan result") - } - }, - onProgress = { stateAny -> - try { - val stateIndex = - when (stateAny) { - is Number -> stateAny.toInt() - else -> 0 - } - val state = MrzDetectionState.entries.getOrNull(stateIndex) ?: MrzDetectionState.NO_TEXT - onProgress(state) - } catch (e: Exception) { - Logger.e("MrzScan", "Failed to convert progress state", e) - } - }, - onError = { error -> - onError(error) - }, - ) - } - - onError("MRZ camera not configured. Call SelfSdkSwift.configure() first.") + onError("MRZ camera not configured. cameraMrz provider is not in current scope.") return UIView().apply { backgroundColor = UIColor.blackColor } } diff --git a/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/NfcScanScreen.ios.kt b/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/NfcScanScreen.ios.kt index d5d520f3e..f5f966296 100644 --- a/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/NfcScanScreen.ios.kt +++ b/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/NfcScanScreen.ios.kt @@ -17,15 +17,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import xyz.self.sdk.models.NfcScanState -import xyz.self.sdk.providers.SdkProviderRegistry import xyz.self.testapp.components.NfcProgressIndicator import xyz.self.testapp.models.VerificationFlowState -import xyz.self.testapp.utils.Logger import xyz.self.testapp.viewmodels.VerificationViewModel -import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @OptIn(ExperimentalForeignApi::class, ExperimentalMaterial3Api::class) @@ -222,15 +218,15 @@ fun NfcScanScreen( /** * Checks if NFC is available on this device + * NOTE: Temporarily disabled — nfc provider moved out of 3-domain scope. + * Will be re-enabled when NFC provider is added back. */ @OptIn(ExperimentalForeignApi::class) -private fun isNfcAvailable(): Boolean { - val provider = SdkProviderRegistry.nfc ?: return false - return provider.isAvailable() -} +private fun isNfcAvailable(): Boolean = false /** - * Scans passport using NFC via Swift helper (through SdkProviderRegistry) + * Scans passport using NFC via Swift helper + * NOTE: Temporarily disabled — nfc provider moved out of 3-domain scope. */ private suspend fun scanPassportWithNfc( passportNumber: String, @@ -239,51 +235,7 @@ private suspend fun scanPassportWithNfc( onProgress: (NfcScanState) -> Unit, ): JsonElement = suspendCancellableCoroutine { cont -> - val provider = SdkProviderRegistry.nfc - if (provider == null) { - cont.resumeWithException( - Exception("NFC provider not configured. Call SelfSdkSwift.configure() first."), - ) - return@suspendCancellableCoroutine - } - - provider.scanPassport( - passportNumber = passportNumber, - dateOfBirth = dateOfBirth, - dateOfExpiry = dateOfExpiry, - onProgress = { stateAny -> - try { - val stateIndex = - when (stateAny) { - is Number -> stateAny.toInt() - else -> 0 - } - val state = NfcScanState.entries.getOrNull(stateIndex) - if (state != null) { - onProgress(state) - } - } catch (e: Exception) { - Logger.e("NfcScan", "Failed to convert progress state", e) - } - }, - onComplete = { result -> - if (cont.isActive) { - try { - val jsonElement = Json.parseToJsonElement(result) - cont.resume(jsonElement) - } catch (e: Exception) { - cont.resumeWithException(Exception("Failed to parse NFC result: ${e.message}")) - } - } - }, - onError = { error -> - if (cont.isActive) { - cont.resumeWithException(Exception(error)) - } - }, + cont.resumeWithException( + Exception("NFC provider not configured. nfc provider is not in current scope."), ) - - cont.invokeOnCancellation { - provider.cancelScan() - } } diff --git a/packages/kmp-sdk-test-app/iosApp/iosApp.xcodeproj/project.pbxproj b/packages/kmp-sdk-test-app/iosApp/iosApp.xcodeproj/project.pbxproj index 3722397b8..cc78a76a8 100644 --- a/packages/kmp-sdk-test-app/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/packages/kmp-sdk-test-app/iosApp/iosApp.xcodeproj/project.pbxproj @@ -131,7 +131,7 @@ ); mainGroup = B10000040000000000000001; packageReferences = ( - 0979CBA62F44010F007B599C /* XCLocalSwiftPackageReference "self-sdk-swift" */, + 0979CBA62F44010F007B599C /* XCLocalSwiftPackageReference "../../self-sdk-swift" */, ); productRefGroup = B10000040000000000000003 /* Products */; projectDirPath = ""; @@ -192,7 +192,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ -z \"$JAVA_HOME\" ] || [ ! -d \"$JAVA_HOME\" ]; then\n export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || echo /opt/homebrew/opt/openjdk@17)\nfi\nexport PATH=\"$JAVA_HOME/bin:$PATH\"\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -269,7 +269,7 @@ CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5B29R5LYHQ; + DEVELOPMENT_TEAM = HU25D6ZXR2; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -278,9 +278,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Self Test"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "This app needs access to your camera to scan the MRZ code on your passport."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = arm64; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -293,7 +293,7 @@ "-framework", ComposeApp, ); - PRODUCT_BUNDLE_IDENTIFIER = xyz.self.testapp; + PRODUCT_BUNDLE_IDENTIFIER = xyz.self.kmptestapp; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -309,7 +309,7 @@ CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5B29R5LYHQ; + DEVELOPMENT_TEAM = HU25D6ZXR2; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -318,9 +318,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Self Test"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "This app needs access to your camera to scan the MRZ code on your passport."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = arm64; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -333,7 +333,7 @@ "-framework", ComposeApp, ); - PRODUCT_BUNDLE_IDENTIFIER = xyz.self.testapp; + PRODUCT_BUNDLE_IDENTIFIER = xyz.self.kmptestapp; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -365,7 +365,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 0979CBA62F44010F007B599C /* XCLocalSwiftPackageReference "self-sdk-swift" */ = { + 0979CBA62F44010F007B599C /* XCLocalSwiftPackageReference "../../self-sdk-swift" */ = { isa = XCLocalSwiftPackageReference; relativePath = "../../self-sdk-swift"; }; diff --git a/packages/kmp-sdk-test-app/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/packages/kmp-sdk-test-app/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 000000000..5bc7d9c80 --- /dev/null +++ b/packages/kmp-sdk-test-app/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/kmp-sdk-test-app/iosApp/iosApp/Info.plist b/packages/kmp-sdk-test-app/iosApp/iosApp/Info.plist index 4b1f0e71b..771d1c27a 100644 --- a/packages/kmp-sdk-test-app/iosApp/iosApp/Info.plist +++ b/packages/kmp-sdk-test-app/iosApp/iosApp/Info.plist @@ -2,23 +2,7 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS + CADisableMinimumFrameDurationOnPhone UIApplicationSceneManifest @@ -27,27 +11,18 @@ UILaunchScreen - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIRequiredDeviceCapabilities - - armv7 - - NFCReaderUsageDescription - This app needs access to NFC to read your passport for identity verification. + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + com.apple.developer.nfc.readersession.iso7816.select-identifiers A0000002471001 A0000002472001 00000000000000 - NSCameraUsageDescription - This app needs access to your camera to scan the MRZ code on your passport. diff --git a/packages/kmp-sdk-test-app/iosApp/iosApp/iOSApp.swift b/packages/kmp-sdk-test-app/iosApp/iosApp/iOSApp.swift index 6ed233e65..6218bbc3d 100644 --- a/packages/kmp-sdk-test-app/iosApp/iosApp/iOSApp.swift +++ b/packages/kmp-sdk-test-app/iosApp/iosApp/iOSApp.swift @@ -6,32 +6,18 @@ import SwiftUI import ComposeApp import SelfSdkSwift -// MARK: - Protocol conformance declarations -// These bridge the SelfSdkSwift implementations to the Kotlin provider protocols -// exported from the ComposeApp (KMP) framework. - -extension BiometricProviderImpl: BiometricProvider {} +// MARK: - Protocol conformance for required providers extension SecureStorageProviderImpl: SecureStorageProvider {} -extension HapticProviderImpl: HapticProvider {} extension CryptoProviderImpl: CryptoProvider {} -extension DocumentsProviderImpl: DocumentsProvider {} extension WebViewProviderImpl: WebViewProvider {} -extension NfcProviderImpl: NfcProvider {} -extension CameraMrzProviderImpl: CameraMrzProvider {} @main struct iOSApp: App { init() { - // Register all Swift provider implementations with the KMP SdkProviderRegistry - let registry = SdkProviderRegistry.shared - registry.biometric = BiometricProviderImpl() - registry.secureStorage = SecureStorageProviderImpl() - registry.haptic = HapticProviderImpl() - registry.crypto = CryptoProviderImpl() - registry.documents = DocumentsProviderImpl() - registry.webView = WebViewProviderImpl() - registry.nfc = NfcProviderImpl() - registry.cameraMrz = CameraMrzProviderImpl() + // Register only the 3-domain required providers + SdkProviderRegistry.shared.secureStorage = SecureStorageProviderImpl() + SdkProviderRegistry.shared.crypto = CryptoProviderImpl() + IosProviderRegistry.shared.webView = WebViewProviderImpl() } var body: some Scene { diff --git a/packages/kmp-sdk-test-app/iosApp/iosApp/iosApp.entitlements b/packages/kmp-sdk-test-app/iosApp/iosApp/iosApp.entitlements index 91c987219..0c67376eb 100644 --- a/packages/kmp-sdk-test-app/iosApp/iosApp/iosApp.entitlements +++ b/packages/kmp-sdk-test-app/iosApp/iosApp/iosApp.entitlements @@ -1,11 +1,5 @@ - - com.apple.developer.nfc.readersession.formats - - NDEF - TAG - - + diff --git a/packages/kmp-sdk/shared/build.gradle.kts b/packages/kmp-sdk/shared/build.gradle.kts index ab9cb86fb..7d6abb275 100644 --- a/packages/kmp-sdk/shared/build.gradle.kts +++ b/packages/kmp-sdk/shared/build.gradle.kts @@ -76,25 +76,23 @@ kotlin { androidMain.dependencies { // WebView implementation("androidx.webkit:webkit:1.12.1") - // NFC / Passport reading + // Encrypted storage (default SecureStorageProvider) + implementation("androidx.security:security-crypto:1.1.0-alpha06") + // Activity / Lifecycle + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + // Retained handler dependencies (not registered in 3-domain scope, but kept for future use) implementation("org.jmrtd:jmrtd:0.8.1") implementation("net.sf.scuba:scuba-sc-android:0.0.18") implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") implementation("commons-io:commons-io:2.14.0") - // Biometrics implementation("androidx.biometric:biometric:1.2.0-alpha05") - // Encrypted storage - implementation("androidx.security:security-crypto:1.1.0-alpha06") - // Camera / MRZ scanning implementation("com.google.mlkit:text-recognition:16.0.1") implementation("androidx.camera:camera-core:1.4.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") - // Activity / Lifecycle - implementation("androidx.appcompat:appcompat:1.7.0") - implementation("androidx.activity:activity-ktx:1.9.3") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") } } } diff --git a/packages/kmp-sdk/shared/src/androidMain/AndroidManifest.xml b/packages/kmp-sdk/shared/src/androidMain/AndroidManifest.xml index 83d0ac6ee..b6cb6e92c 100644 --- a/packages/kmp-sdk/shared/src/androidMain/AndroidManifest.xml +++ b/packages/kmp-sdk/shared/src/androidMain/AndroidManifest.xml @@ -1,25 +1,12 @@ - - - - - - - - - - - - - - + + + + + diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt deleted file mode 100644 index aac5f157f..000000000 --- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. -// SPDX-License-Identifier: BUSL-1.1 -// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. - -package xyz.self.sdk.handlers - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonPrimitive -import xyz.self.sdk.bridge.BridgeDomain -import xyz.self.sdk.bridge.BridgeHandler -import xyz.self.sdk.bridge.BridgeHandlerException - -/** - * Android implementation of secure storage bridge handler. - * Uses EncryptedSharedPreferences backed by Android Keystore for secure key-value storage. - */ -class SecureStorageBridgeHandler( - context: Context, -) : BridgeHandler { - override val domain = BridgeDomain.SECURE_STORAGE - - private val prefs: SharedPreferences - - init { - // Create master key for encryption - val masterKey = - MasterKey - .Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - // Create encrypted shared preferences - prefs = - EncryptedSharedPreferences.create( - context, - "self_sdk_secure_prefs", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } - - 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", - ) - } - - /** - * Retrieves a value from secure storage. - * Returns the value as a string, or null if the key doesn't exist. - */ - private fun get(params: Map): JsonElement { - val key = - params["key"]?.jsonPrimitive?.content - ?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required") - - val value = prefs.getString(key, null) - - return if (value != null) { - JsonPrimitive(value) - } else { - JsonNull - } - } - - /** - * Stores a value in secure storage. - * The value is encrypted using Android Keystore. - */ - private fun set(params: Map): JsonElement? { - 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") - - prefs.edit().putString(key, value).apply() - - return null // Success with no return value - } - - /** - * Removes a value from secure storage. - */ - private fun remove(params: Map): JsonElement? { - val key = - params["key"]?.jsonPrimitive?.content - ?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required") - - prefs.edit().remove(key).apply() - - return null // Success with no return value - } - - /** - * Clears all values from secure storage. - */ - private fun clear(): JsonElement? { - prefs.edit().clear().apply() - return null // Success with no return value - } -} diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/AndroidKeystoreCryptoProvider.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/AndroidKeystoreCryptoProvider.kt new file mode 100644 index 000000000..022b22161 --- /dev/null +++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/AndroidKeystoreCryptoProvider.kt @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +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 + +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) + } + } +} diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/EncryptedSharedPreferencesProvider.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/EncryptedSharedPreferencesProvider.kt new file mode 100644 index 000000000..ca6a08c16 --- /dev/null +++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/providers/EncryptedSharedPreferencesProvider.kt @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +package xyz.self.sdk.providers + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +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() + } +} diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt index 1d9b745ab..3fc66000b 100644 --- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt +++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt @@ -4,88 +4,45 @@ package xyz.self.sdk.webview +import android.Manifest import android.annotation.SuppressLint +import android.app.Activity import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri import android.net.http.SslError import android.webkit.JavascriptInterface +import android.webkit.PermissionRequest import android.webkit.SslErrorHandler +import android.webkit.ValueCallback +import android.webkit.WebChromeClient import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient -import androidx.webkit.WebViewAssetLoader +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import xyz.self.sdk.bridge.MessageRouter -/** - * Manages an Android WebView instance for hosting the Self verification UI. - * Handles bidirectional communication between WebView JavaScript and native Kotlin code. - * - * Uses WebViewAssetLoader to serve bundled assets under https://appassets.androidplatform.net/ - * so the WebView has a proper origin for History API, CORS, and other web platform features. - */ class AndroidWebViewHost( private val context: Context, private val router: MessageRouter, private val isDebugMode: Boolean = false, ) { private lateinit var webView: WebView + var pendingPermissionRequest: PermissionRequest? = null + var fileUploadCallback: ValueCallback>? = null - /** - * Creates and configures the WebView with security settings and bridge communication. - */ @SuppressLint("SetJavaScriptEnabled") - fun createWebView(): WebView { - // WebViewAssetLoader serves files from android_asset/ under a proper https:// domain, - // avoiding file:// origin issues with History API, CORS, etc. - // Custom PathHandler that serves from the self-wallet/ subdirectory of assets. - // This way, a request to /assets/foo.js resolves to self-wallet/assets/foo.js - // and /index.html resolves to self-wallet/index.html. - val selfWalletHandler = - WebViewAssetLoader.PathHandler { path -> - try { - val assetPath = "self-wallet/$path" - val inputStream = context.assets.open(assetPath) - val mimeType = - when { - path.endsWith(".js") -> "application/javascript" - path.endsWith(".css") -> "text/css" - path.endsWith(".html") -> "text/html" - path.endsWith(".json") -> "application/json" - path.endsWith(".woff2") -> "font/woff2" - path.endsWith(".woff") -> "font/woff" - path.endsWith(".otf") -> "font/otf" - path.endsWith(".ttf") -> "font/ttf" - path.endsWith(".png") -> "image/png" - path.endsWith(".svg") -> "image/svg+xml" - else -> "application/octet-stream" - } - WebResourceResponse(mimeType, "UTF-8", inputStream) - } catch (e: Exception) { - null - } - } - - val assetLoader = - WebViewAssetLoader - .Builder() - .addPathHandler("/", selfWalletHandler) - .build() - + fun createWebView(queryParams: String = ""): WebView { webView = WebView(context).apply { settings.apply { - // Enable JavaScript for bridge communication javaScriptEnabled = true domStorageEnabled = true - - // File access not needed — assets served via WebViewAssetLoader allowFileAccess = false allowContentAccess = false - - // Media playback mediaPlaybackRequiresUserGesture = false - // Enable debugging in debug mode if (isDebugMode) { WebView.setWebContentsDebuggingEnabled(true) } @@ -93,23 +50,15 @@ class AndroidWebViewHost( webViewClient = object : WebViewClient() { - override fun shouldInterceptRequest( - view: WebView?, - request: WebResourceRequest?, - ): WebResourceResponse? { - request ?: return null - return assetLoader.shouldInterceptRequest(request.url) - } - override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest?, ): Boolean { - val url = request?.url?.toString() ?: return true - val assetHost = "https://appassets.androidplatform.net/" - if (url.startsWith(assetHost)) return false - if (isDebugMode && url.startsWith("http://127.0.0.1:5173")) return false - return true // block everything else + val uri = request?.url ?: return true + val isAllowed = + (uri.scheme == "https" && uri.host == "self-app-alpha.vercel.app") || + (isDebugMode && uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 5173) + return !isAllowed } override fun onReceivedSslError( @@ -121,30 +70,99 @@ class AndroidWebViewHost( } } - // Register JS interface: WebView → Native communication - // JavaScript can call: window.SelfNativeAndroid.postMessage(json) + webChromeClient = + object : WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest?) { + request ?: return + val origin = + request.origin ?: run { + request.deny() + return + } + val isTrusted = + (origin.scheme == "https" && origin.host == "self-app-alpha.vercel.app") || + (origin.scheme == "https" && origin.host == "verify.didit.me") || + (isDebugMode && origin.scheme == "http" && origin.host == "127.0.0.1") + if (!isTrusted) { + request.deny() + return + } + + val activity = + context as? Activity ?: run { + request.deny() + return + } + + val allowedResources = + request.resources.filter { + it == PermissionRequest.RESOURCE_VIDEO_CAPTURE || + it == PermissionRequest.RESOURCE_AUDIO_CAPTURE + } + if (allowedResources.size != request.resources.size) { + request.deny() + return + } + + val neededPermissions = mutableListOf() + if (allowedResources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) { + neededPermissions.add(Manifest.permission.CAMERA) + } + if (allowedResources.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(allowedResources.toTypedArray()) + } + + 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 { + @Suppress("DEPRECATION") + activity.startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE) + } catch (e: Exception) { + fileUploadCallback = null + return false + } + return true + } + } + addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid") - // Load appropriate URL based on mode - if (isDebugMode) { - // Development mode: connect to Vite dev server - // With adb reverse, Android devices can use localhost. - loadUrl("http://127.0.0.1:5173") - } else { - // Production mode: load via WebViewAssetLoader. - // The custom PathHandler prepends self-wallet/ to all paths, - // so /index.html → self-wallet/index.html in assets, - // and /assets/foo.js → self-wallet/assets/foo.js in assets. - loadUrl("https://appassets.androidplatform.net/index.html") - } + val baseUrl = "https://self-app-alpha.vercel.app/tunnel/tour/1" + val url = if (queryParams.isNotEmpty()) "$baseUrl?$queryParams" else baseUrl + loadUrl(url) } return webView } - /** - * Sends JavaScript code to the WebView for execution. - * Used for Native → WebView communication (responses and events). - */ fun evaluateJs(js: String) { if (!::webView.isInitialized) return webView.evaluateJavascript(js, null) @@ -155,19 +173,15 @@ class AndroidWebViewHost( webView.destroy() } - /** - * JavaScript interface exposed to WebView. - * Allows WebView to send bridge messages to native code. - */ inner class BridgeJsInterface { - /** - * Called from JavaScript when a bridge request is sent. - * JavaScript usage: window.SelfNativeAndroid.postMessage(JSON.stringify(message)) - */ @JavascriptInterface fun postMessage(json: String) { - // Forward to MessageRouter for processing router.onMessageReceived(json) } } + + companion object { + const val FILE_CHOOSER_REQUEST_CODE = 1001 + const val CAMERA_PERMISSION_REQUEST_CODE = 1002 + } } diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt index 2de024419..43d1fe2ca 100644 --- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt +++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt @@ -4,104 +4,170 @@ package xyz.self.sdk.webview -import android.Manifest +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts +import android.webkit.WebChromeClient import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat import xyz.self.sdk.bridge.MessageRouter -import xyz.self.sdk.handlers.BiometricBridgeHandler -import xyz.self.sdk.handlers.CameraMrzBridgeHandler +import xyz.self.sdk.handlers.CryptoBridgeHandler import xyz.self.sdk.handlers.LifecycleBridgeHandler -import xyz.self.sdk.handlers.NfcBridgeHandler import xyz.self.sdk.handlers.SecureStorageBridgeHandler +import xyz.self.sdk.providers.AndroidKeystoreCryptoProvider +import xyz.self.sdk.providers.EncryptedSharedPreferencesProvider +import xyz.self.sdk.providers.SdkProviderRegistry -/** - * Activity that hosts the Self verification WebView. - * This is the main entry point for the verification flow. - * Host apps launch this Activity via SelfSdk.launch(). - */ class SelfVerificationActivity : AppCompatActivity() { private lateinit var webViewHost: AndroidWebViewHost private lateinit var router: MessageRouter - private val requiredPermissions = - arrayOf( - Manifest.permission.CAMERA, - Manifest.permission.NFC, - ) - - private val permissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - // Permissions granted or denied — proceed either way. - // Individual handlers will fail gracefully if their permission was denied. - initVerificationFlow() - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Request runtime permissions before initializing the WebView. - // Camera and NFC are dangerous permissions that require user consent. - val missingPermissions = - requiredPermissions.filter { - ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED - } - - if (missingPermissions.isNotEmpty()) { - permissionLauncher.launch(missingPermissions.toTypedArray()) - } else { - initVerificationFlow() - } + initVerificationFlow() } private fun initVerificationFlow() { - // Determine if we're in debug mode val isDebugMode = intent.getBooleanExtra(EXTRA_DEBUG_MODE, false) - // Create router with callback to send JavaScript to WebView + // 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 = MessageRouter( sendToWebView = { js -> - // Ensure we're on the UI thread runOnUiThread { webViewHost.evaluateJs(js) } }, ) - // Register all native bridge handlers - // These handlers implement the bridge protocol domains registerHandlers() - // Create and display WebView + // Build query params from VerificationRequest JSON + val queryParams = buildQueryParams() + if (queryParams == null) { + setResult( + RESULT_CODE_ERROR, + Intent().apply { + putExtra(EXTRA_ERROR_CODE, "INVALID_BOOTSTRAP") + putExtra(EXTRA_ERROR_MESSAGE, "Invalid verification request/config payload") + }, + ) + finish() + return + } + webViewHost = AndroidWebViewHost(this, router, isDebugMode) - val webView = webViewHost.createWebView() + val webView = webViewHost.createWebView(queryParams) setContentView(webView) } - /** - * Registers all bridge handlers with the MessageRouter. - * Each handler implements a specific domain of the bridge protocol. - */ private fun registerHandlers() { - // NFC - Passport scanning - router.register(NfcBridgeHandler(this, router)) - - // Camera - MRZ scanning - router.register(CameraMrzBridgeHandler(this)) - - // Biometrics - Fingerprint/Face authentication - router.register(BiometricBridgeHandler(this)) - - // Secure Storage - Encrypted key-value storage - router.register(SecureStorageBridgeHandler(this)) - - // Lifecycle - WebView lifecycle management + router.register(SecureStorageBridgeHandler()) + router.register(CryptoBridgeHandler()) router.register(LifecycleBridgeHandler(this)) } + private fun buildQueryParams(): String? { + val requestJson = intent.getStringExtra(EXTRA_VERIFICATION_REQUEST) ?: return null + val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}" + return try { + val json = org.json.JSONObject(requestJson) + val config = org.json.JSONObject(configJson) + buildString { + var first = true + + fun append( + key: String, + value: String?, + ) { + if (value.isNullOrEmpty()) return + if (!first) append("&") + append("$key=${Uri.encode(value)}") + first = false + } + + // Config params (always present) + val endpoint = config.optString("endpoint", "https://api.self.xyz") + append("endpoint", endpoint) + val appEndpoint = config.optString("appEndpoint", null) + append("appEndpoint", if (appEndpoint.isNullOrEmpty()) endpoint else appEndpoint) + append("environment", config.optString("environment", "prod")) + append("version", config.optInt("version", 1).toString()) + + // Optional config params + append("appName", config.optString("appName", null)) + append("endpointType", config.optString("endpointType", null)) + val chainID = config.optInt("chainID", 0) + if (chainID != 0) append("chainID", chainID.toString()) + + // Request params + append("verificationId", json.optString("verificationId", null)) + append("userId", json.optString("userId", null)) + append("scope", json.optString("scope", null)) + val disclosures = json.optJSONArray("disclosures") + if (disclosures != null && disclosures.length() > 0) { + val items = (0 until disclosures.length()).map { disclosures.getString(it) } + append("disclosures", items.joinToString(",")) + } + append("resultType", json.optString("resultType", null)) + val excludedCountries = json.optJSONArray("excludedCountries") + if (excludedCountries != null && excludedCountries.length() > 0) { + val items = (0 until excludedCountries.length()).map { excludedCountries.getString(it) } + append("excludedCountries", items.joinToString(",")) + } + append("userIdType", json.optString("userIdType", null)) + append("userDefinedData", json.optString("userDefinedData", null)) + append("selfDefinedData", json.optString("selfDefinedData", null)) + } + } catch (_: Exception) { + null + } + } + + 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.all { it == 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?, + ) { + @Suppress("DEPRECATION") + 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 + } + } + override fun onDestroy() { if (::webViewHost.isInitialized) { webViewHost.destroy() @@ -114,12 +180,10 @@ class SelfVerificationActivity : AppCompatActivity() { const val EXTRA_VERIFICATION_REQUEST = "xyz.self.sdk.VERIFICATION_REQUEST" const val EXTRA_CONFIG = "xyz.self.sdk.CONFIG" - // Activity result codes const val RESULT_CODE_SUCCESS = RESULT_OK const val RESULT_CODE_ERROR = RESULT_FIRST_USER const val RESULT_CODE_CANCELLED = RESULT_CANCELED - // Result extras const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA" const val EXTRA_RESULT_TYPE = "xyz.self.sdk.RESULT_TYPE" const val EXTRA_ERROR_CODE = "xyz.self.sdk.ERROR_CODE" diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt index daeb02864..3aaac15f5 100644 --- a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt @@ -4,10 +4,34 @@ package xyz.self.sdk.api +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +enum class SelfEnvironment { + @SerialName("prod") + PROD, + + @SerialName("stg") + STG, + ; + + val queryValue: String + get() = + when (this) { + PROD -> "prod" + STG -> "stg" + } +} + @Serializable data class SelfSdkConfig( val endpoint: String = "https://api.self.xyz", + val environment: SelfEnvironment = SelfEnvironment.PROD, val debug: Boolean = false, + val version: Int = 1, + val appName: String? = null, + val appEndpoint: String? = null, + val endpointType: String? = null, + val chainID: Int? = null, ) diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/VerificationRequest.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/VerificationRequest.kt index f08cf15cf..8b55a9dac 100644 --- a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/VerificationRequest.kt +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/VerificationRequest.kt @@ -11,4 +11,10 @@ data class VerificationRequest( val userId: String? = null, val scope: String? = null, val disclosures: List = emptyList(), + val verificationId: String? = null, + val resultType: String? = null, + val excludedCountries: List = emptyList(), + val userIdType: String? = null, + val userDefinedData: String? = null, + val selfDefinedData: String? = null, ) diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt index 77c5e4998..8ddb5a95b 100644 --- a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt @@ -17,7 +17,11 @@ class MessageRouter( private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), ) { private val handlers = mutableMapOf() - private val json = Json { ignoreUnknownKeys = true } + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } fun register(handler: BridgeHandler) { handlers[handler.domain] = handler @@ -31,6 +35,23 @@ class MessageRouter( return // Malformed message — drop silently } + if (request.version != BRIDGE_PROTOCOL_VERSION) { + sendResponse( + BridgeResponse( + id = generateUuid(), + domain = request.domain, + requestId = request.id, + success = false, + error = + BridgeError( + code = "UNSUPPORTED_VERSION", + message = "Expected protocol version $BRIDGE_PROTOCOL_VERSION, got ${request.version}", + ), + ), + ) + return + } + val handler = handlers[request.domain] if (handler == null) { sendResponse( diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/CryptoBridgeHandler.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/CryptoBridgeHandler.kt similarity index 100% rename from packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/CryptoBridgeHandler.kt rename to packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/CryptoBridgeHandler.kt diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt similarity index 94% rename from packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt rename to packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt index 21a8a9b9e..5c438f920 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/SecureStorageBridgeHandler.kt @@ -7,6 +7,7 @@ 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 @@ -35,27 +36,25 @@ class SecureStorageBridgeHandler : BridgeHandler { 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 if (value != null) JsonPrimitive(value) else JsonNull + 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 } @@ -64,11 +63,9 @@ class SecureStorageBridgeHandler : BridgeHandler { 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 } @@ -77,7 +74,6 @@ class SecureStorageBridgeHandler : BridgeHandler { val provider = SdkProviderRegistry.secureStorage ?: throw BridgeHandlerException("NOT_CONFIGURED", "SecureStorage provider not configured") - provider.clear() return null } diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/CryptoProvider.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/CryptoProvider.kt similarity index 100% rename from packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/CryptoProvider.kt rename to packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/CryptoProvider.kt diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt new file mode 100644 index 000000000..1e156d724 --- /dev/null +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +package xyz.self.sdk.providers + +object SdkProviderRegistry { + var secureStorage: SecureStorageProvider? = null + var crypto: CryptoProvider? = null + + /** + * Returns true if the required 3-domain providers are configured. + * Only secureStorage and crypto are required — lifecycle is handler-only + * with no consumer-provided provider. + */ + fun isConfigured(): Boolean = secureStorage != null && crypto != null + + fun reset() { + secureStorage = null + crypto = null + } +} diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/SecureStorageProvider.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/SecureStorageProvider.kt similarity index 100% rename from packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/SecureStorageProvider.kt rename to packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/providers/SecureStorageProvider.kt diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt index 67f773d5e..45d586b4c 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt @@ -14,15 +14,10 @@ import platform.UIKit.UIWindowScene import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue import xyz.self.sdk.bridge.MessageRouter -import xyz.self.sdk.handlers.AnalyticsBridgeHandler -import xyz.self.sdk.handlers.BiometricBridgeHandler -import xyz.self.sdk.handlers.CameraMrzBridgeHandler import xyz.self.sdk.handlers.CryptoBridgeHandler -import xyz.self.sdk.handlers.DocumentsBridgeHandler -import xyz.self.sdk.handlers.HapticBridgeHandler import xyz.self.sdk.handlers.LifecycleBridgeHandler -import xyz.self.sdk.handlers.NfcBridgeHandler import xyz.self.sdk.handlers.SecureStorageBridgeHandler +import xyz.self.sdk.providers.IosProviderRegistry import xyz.self.sdk.providers.SdkProviderRegistry import xyz.self.sdk.webview.IosWebViewHost @@ -59,9 +54,10 @@ actual class SelfSdk private constructor( request: VerificationRequest, callback: SelfSdkCallback, ) { - check(SdkProviderRegistry.isConfigured()) { - "SdkProviderRegistry is not configured. " + - "Call SelfSdkSwift.configure() from your iOS app before launching the SDK." + check(SdkProviderRegistry.isConfigured() && IosProviderRegistry.webView != null) { + "SDK providers not configured. " + + "Call SelfSdkSwift.configure() from your iOS app before launching the SDK. " + + "Required: secureStorage, crypto, and webView providers." } // Store callback for later @@ -97,17 +93,20 @@ actual class SelfSdk private constructor( ) } - // Register all iOS bridge handlers + // Register 3-domain bridge handlers registerHandlers(router!!, lifecycleHandler) + // Build query params from config + request + val queryParams = buildQueryParams(request) + // Create WebView host and the web view webViewHost = IosWebViewHost(router!!, config.debug) - webViewHost!!.createWebView() + webViewHost!!.createWebView(queryParams) // Get the ViewController from the WebView provider and present it val sdkVC = ( - SdkProviderRegistry.webView + IosProviderRegistry.webView ?: throw IllegalStateException("WebView provider not configured. Call SelfSdkSwift.configure() first.") ).getViewController() sdkVC.setModalPresentationStyle(UIModalPresentationFullScreen) @@ -146,14 +145,48 @@ actual class SelfSdk private constructor( router: MessageRouter, lifecycleHandler: LifecycleBridgeHandler, ) { - 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)) } + + private fun buildQueryParams(request: VerificationRequest): String? { + val parts = mutableListOf() + + // Config params (always present) + parts.add("endpoint=${encodeParam(config.endpoint)}") + parts.add("appEndpoint=${encodeParam(config.appEndpoint ?: config.endpoint)}") + parts.add("environment=${encodeParam(config.environment.queryValue)}") + parts.add("version=${config.version}") + + // Optional config params + config.appName?.let { parts.add("appName=${encodeParam(it)}") } + config.endpointType?.let { parts.add("endpointType=${encodeParam(it)}") } + config.chainID?.let { parts.add("chainID=$it") } + + // Request params + request.verificationId?.let { parts.add("verificationId=${encodeParam(it)}") } + request.userId?.let { parts.add("userId=${encodeParam(it)}") } + request.scope?.let { parts.add("scope=${encodeParam(it)}") } + if (request.disclosures.isNotEmpty()) { + parts.add("disclosures=${encodeParam(request.disclosures.joinToString(","))}") + } + request.resultType?.let { parts.add("resultType=${encodeParam(it)}") } + if (request.excludedCountries.isNotEmpty()) { + parts.add("excludedCountries=${encodeParam(request.excludedCountries.joinToString(","))}") + } + request.userIdType?.let { parts.add("userIdType=${encodeParam(it)}") } + request.userDefinedData?.let { parts.add("userDefinedData=${encodeParam(it)}") } + request.selfDefinedData?.let { parts.add("selfDefinedData=${encodeParam(it)}") } + + return parts.joinToString("&").ifEmpty { null } + } + + private fun encodeParam(value: String): String = + value + .replace("%", "%25") + .replace("&", "%26") + .replace("=", "%3D") + .replace("+", "%2B") + .replace(" ", "%20") } diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/BiometricBridgeHandler.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/BiometricBridgeHandler.kt index 0f3f323cc..2575be7f6 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/BiometricBridgeHandler.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/BiometricBridgeHandler.kt @@ -11,7 +11,7 @@ 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 +import xyz.self.sdk.providers.IosProviderRegistry import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -34,7 +34,7 @@ class BiometricBridgeHandler : BridgeHandler { private suspend fun authenticate(params: Map): JsonElement { val provider = - SdkProviderRegistry.biometric + IosProviderRegistry.biometric ?: throw BridgeHandlerException("NOT_CONFIGURED", "Biometric provider not configured") val reason = params["reason"]?.jsonPrimitive?.content ?: "Authenticate to continue" @@ -66,12 +66,12 @@ class BiometricBridgeHandler : BridgeHandler { } private fun isAvailable(): JsonElement { - val provider = SdkProviderRegistry.biometric ?: return JsonPrimitive(false) + val provider = IosProviderRegistry.biometric ?: return JsonPrimitive(false) return JsonPrimitive(provider.isAvailable()) } private fun getBiometryType(): JsonElement { - val provider = SdkProviderRegistry.biometric ?: return JsonPrimitive("none") + val provider = IosProviderRegistry.biometric ?: return JsonPrimitive("none") return JsonPrimitive(provider.getBiometryType()) } } diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/CameraMrzBridgeHandler.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/CameraMrzBridgeHandler.kt index dd5ca4dbe..57f2d367c 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/CameraMrzBridgeHandler.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/CameraMrzBridgeHandler.kt @@ -11,7 +11,7 @@ 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 +import xyz.self.sdk.providers.IosProviderRegistry import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -34,7 +34,7 @@ class CameraMrzBridgeHandler : BridgeHandler { private suspend fun scanMRZ(): JsonElement = suspendCancellableCoroutine { continuation -> - val provider = SdkProviderRegistry.cameraMrz + val provider = IosProviderRegistry.cameraMrz if (provider == null) { continuation.resumeWithException( BridgeHandlerException("NOT_CONFIGURED", "CameraMrz provider not configured"), @@ -71,12 +71,12 @@ class CameraMrzBridgeHandler : BridgeHandler { } private fun isAvailable(): JsonElement { - val provider = SdkProviderRegistry.cameraMrz ?: return JsonPrimitive(false) + val provider = IosProviderRegistry.cameraMrz ?: return JsonPrimitive(false) return JsonPrimitive(provider.isAvailable()) } private fun stopCamera(): JsonElement? { - SdkProviderRegistry.cameraMrz?.stopCamera() + IosProviderRegistry.cameraMrz?.stopCamera() return null } } diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/DocumentsBridgeHandler.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/DocumentsBridgeHandler.kt index 515a1cc51..803c14af3 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/DocumentsBridgeHandler.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/DocumentsBridgeHandler.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.json.put import xyz.self.sdk.bridge.BridgeDomain import xyz.self.sdk.bridge.BridgeHandler import xyz.self.sdk.bridge.BridgeHandlerException -import xyz.self.sdk.providers.SdkProviderRegistry +import xyz.self.sdk.providers.IosProviderRegistry class DocumentsBridgeHandler : BridgeHandler { override val domain = BridgeDomain.DOCUMENTS @@ -36,7 +36,7 @@ class DocumentsBridgeHandler : BridgeHandler { private fun loadCatalog(): JsonElement { val provider = - SdkProviderRegistry.documents + IosProviderRegistry.documents ?: throw BridgeHandlerException("NOT_CONFIGURED", "Documents provider not configured") val catalogJson = @@ -50,7 +50,7 @@ class DocumentsBridgeHandler : BridgeHandler { private fun saveCatalog(params: Map): JsonElement? { val provider = - SdkProviderRegistry.documents + IosProviderRegistry.documents ?: throw BridgeHandlerException("NOT_CONFIGURED", "Documents provider not configured") val catalogData = @@ -67,7 +67,7 @@ class DocumentsBridgeHandler : BridgeHandler { private fun loadById(params: Map): JsonElement { val provider = - SdkProviderRegistry.documents + IosProviderRegistry.documents ?: throw BridgeHandlerException("NOT_CONFIGURED", "Documents provider not configured") val id = @@ -85,7 +85,7 @@ class DocumentsBridgeHandler : BridgeHandler { private fun save(params: Map): JsonElement? { val provider = - SdkProviderRegistry.documents + IosProviderRegistry.documents ?: throw BridgeHandlerException("NOT_CONFIGURED", "Documents provider not configured") val id = @@ -109,7 +109,7 @@ class DocumentsBridgeHandler : BridgeHandler { private fun delete(params: Map): JsonElement? { val provider = - SdkProviderRegistry.documents + IosProviderRegistry.documents ?: throw BridgeHandlerException("NOT_CONFIGURED", "Documents provider not configured") val id = diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/HapticBridgeHandler.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/HapticBridgeHandler.kt index 93b9c8931..7617122f0 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/HapticBridgeHandler.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/HapticBridgeHandler.kt @@ -10,7 +10,7 @@ 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 +import xyz.self.sdk.providers.IosProviderRegistry class HapticBridgeHandler : BridgeHandler { override val domain = BridgeDomain.HAPTIC @@ -29,14 +29,14 @@ class HapticBridgeHandler : BridgeHandler { } private fun trigger(params: Map): JsonElement? { - val provider = SdkProviderRegistry.haptic ?: return null + val provider = IosProviderRegistry.haptic ?: return null val type = params["type"]?.jsonPrimitive?.content ?: "medium" provider.trigger(type) return null } private fun isAvailable(): JsonElement { - val provider = SdkProviderRegistry.haptic ?: return JsonPrimitive(false) + val provider = IosProviderRegistry.haptic ?: return JsonPrimitive(false) return JsonPrimitive(provider.isAvailable()) } } diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt index 972b002cb..e276920b0 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt @@ -15,7 +15,7 @@ import xyz.self.sdk.bridge.BridgeHandlerException import xyz.self.sdk.bridge.MessageRouter import xyz.self.sdk.models.NfcScanProgress import xyz.self.sdk.models.NfcScanState -import xyz.self.sdk.providers.SdkProviderRegistry +import xyz.self.sdk.providers.IosProviderRegistry import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -43,7 +43,7 @@ class NfcBridgeHandler( private suspend fun scan(params: Map): JsonElement { NfcApduPolicy.requireSupportedParams(params) val provider = - SdkProviderRegistry.nfc + IosProviderRegistry.nfc ?: throw BridgeHandlerException("NOT_CONFIGURED", "NFC provider not configured") val passportNumber = @@ -103,12 +103,12 @@ class NfcBridgeHandler( } private fun cancelScan(): JsonElement? { - SdkProviderRegistry.nfc?.cancelScan() + IosProviderRegistry.nfc?.cancelScan() return null } private fun isSupported(): JsonElement { - val provider = SdkProviderRegistry.nfc ?: return JsonPrimitive(false) + val provider = IosProviderRegistry.nfc ?: return JsonPrimitive(false) return JsonPrimitive(provider.isAvailable()) } } diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/IosProviderRegistry.kt similarity index 61% rename from packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt rename to packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/IosProviderRegistry.kt index ff4c25172..0a916fc65 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/SdkProviderRegistry.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/IosProviderRegistry.kt @@ -4,22 +4,29 @@ package xyz.self.sdk.providers -object SdkProviderRegistry { +object IosProviderRegistry { var biometric: BiometricProvider? = null - var secureStorage: SecureStorageProvider? = null var haptic: HapticProvider? = null - var crypto: CryptoProvider? = null var documents: DocumentsProvider? = null var nfc: NfcProvider? = null var cameraMrz: CameraMrzProvider? = null var webView: WebViewProvider? = null - fun isConfigured(): Boolean = - biometric != null && - secureStorage != null && - crypto != null && + fun isFullyConfigured(): Boolean = + SdkProviderRegistry.isConfigured() && + biometric != null && documents != null && nfc != null && cameraMrz != null && webView != null + + fun reset() { + SdkProviderRegistry.reset() + biometric = null + haptic = null + documents = null + nfc = null + cameraMrz = null + webView = null + } } diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt index b5120ec44..51efdb0b0 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt @@ -13,6 +13,7 @@ interface WebViewProvider { fun createWebView( onMessageReceived: (String) -> Unit, isDebugMode: Boolean, + queryParams: String? = null, ): UIView fun evaluateJs(js: String) diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt index 44f820c79..da934b304 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt @@ -8,16 +8,16 @@ import kotlinx.cinterop.ExperimentalForeignApi import platform.UIKit.UIView import platform.UIKit.UIViewController import xyz.self.sdk.bridge.MessageRouter -import xyz.self.sdk.providers.SdkProviderRegistry +import xyz.self.sdk.providers.IosProviderRegistry @OptIn(ExperimentalForeignApi::class) class IosWebViewHost( private val router: MessageRouter, private val isDebugMode: Boolean = false, ) { - fun createWebView(): UIView { + fun createWebView(queryParams: String? = null): UIView { val provider = - SdkProviderRegistry.webView + IosProviderRegistry.webView ?: throw IllegalStateException("WebView provider not configured") return provider.createWebView( @@ -25,19 +25,20 @@ class IosWebViewHost( router.onMessageReceived(rawJson) }, isDebugMode = isDebugMode, + queryParams = queryParams, ) } fun evaluateJs(js: String) { val provider = - SdkProviderRegistry.webView + IosProviderRegistry.webView ?: throw IllegalStateException("WebView provider not configured") provider.evaluateJs(js) } fun getViewController(): UIViewController { val provider = - SdkProviderRegistry.webView + IosProviderRegistry.webView ?: throw IllegalStateException("WebView provider not configured") return provider.getViewController() } diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift index 3c7ff1f46..4c68f651f 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift @@ -33,6 +33,7 @@ final class SelfWebViewHost: NSObject { webView.isInspectable = isDebugMode } + webView.navigationDelegate = self self.webView = webView return webView } @@ -40,20 +41,15 @@ final class SelfWebViewHost: NSObject { func loadContent(queryParams: String) { guard let webView = webView else { return } - if isDebugMode { - let urlString = "http://localhost:5173/tunnel/tour/1?\(queryParams)" - if let url = URL(string: urlString) { - webView.load(URLRequest(url: url)) - } - } else { - var urlString = "https://self-app-alpha.vercel.app/tunnel/tour/1" - if !queryParams.isEmpty { - urlString += "?\(queryParams)" - } - if let url = URL(string: urlString) { - webView.load(URLRequest(url: url)) - } + var urlString = "https://self-app-alpha.vercel.app/tunnel/tour/1" + if !queryParams.isEmpty { + urlString += "?\(queryParams)" } + guard let url = URL(string: urlString) else { + NSLog("SelfWebViewHost: Failed to construct URL from: %@", urlString) + return + } + webView.load(URLRequest(url: url)) } func evaluateJs(_ js: String) { @@ -63,6 +59,23 @@ final class SelfWebViewHost: NSObject { } } +extension SelfWebViewHost: WKNavigationDelegate { + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url, let host = url.host else { + decisionHandler(.cancel) + return + } + let isTrusted = + (url.scheme == "https" && host == "self-app-alpha.vercel.app") || + (isDebugMode && url.scheme == "http" && host == "127.0.0.1") + decisionHandler(isTrusted ? .allow : .cancel) + } +} + extension SelfWebViewHost: WKScriptMessageHandler { func userContentController( _ userContentController: WKUserContentController, diff --git a/packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift b/packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift index 8e59c667f..2b59444ef 100644 --- a/packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift +++ b/packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift @@ -13,6 +13,7 @@ public class WebViewProviderImpl: NSObject { private var webView: WKWebView? private var viewController: UIViewController? private var onMessageReceived: ((String) -> Void)? + private var isDebugMode: Bool = false /// Weak proxy to avoid retain cycles with WKScriptMessageHandler private var messageProxy: WeakScriptMessageProxy? @@ -21,8 +22,9 @@ public class WebViewProviderImpl: NSObject { super.init() } - @objc(createWebViewOnMessageReceived:isDebugMode:) - public func createWebView(onMessageReceived: @escaping (String) -> Void, isDebugMode: Bool) -> UIView { + @objc(createWebViewOnMessageReceived:isDebugMode:queryParams:) + public func createWebView(onMessageReceived: @escaping (String) -> Void, isDebugMode: Bool, queryParams: String? = nil) -> UIView { + self.isDebugMode = isDebugMode // Clean up existing webView and script handlers before creating new one if let existingWebView = webView { existingWebView.configuration.userContentController.removeScriptMessageHandler(forName: "SelfNativeIOS") @@ -31,7 +33,7 @@ public class WebViewProviderImpl: NSObject { self.webView = nil self.viewController = nil } - + self.onMessageReceived = onMessageReceived // Create message proxy to avoid retain cycle @@ -51,29 +53,26 @@ public class WebViewProviderImpl: NSObject { let wv = WKWebView(frame: .zero, configuration: config) wv.isOpaque = false - wv.backgroundColor = .white + wv.backgroundColor = .clear wv.scrollView.isScrollEnabled = true + wv.scrollView.bounces = false if #available(iOS 16.4, *), isDebugMode { wv.isInspectable = true } + wv.navigationDelegate = self self.webView = wv - // Load the bundled HTML or localhost for debug - if isDebugMode { - if let url = URL(string: "http://localhost:5173") { - wv.load(URLRequest(url: url)) - } - } else { - // Load from app bundle - if let htmlURL = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "self-sdk-web") { - wv.loadFileURL(htmlURL, allowingReadAccessTo: htmlURL.deletingLastPathComponent()) - } else { - NSLog("SelfSDK-WebView: ERROR - index.html not found in self-sdk-web bundle directory") - assertionFailure("SelfSDK: index.html not found in self-sdk-web bundle directory. Ensure the web assets are included in the app bundle.") - } + var urlString = "https://self-app-alpha.vercel.app/tunnel/tour/1" + if let params = queryParams, !params.isEmpty { + urlString += "?\(params)" } + guard let url = URL(string: urlString) else { + NSLog("SelfSDK-WebView: Failed to construct URL from: %@", urlString) + return wv + } + wv.load(URLRequest(url: url)) return wv } @@ -96,15 +95,63 @@ public class WebViewProviderImpl: NSObject { return existingVC } - let vc = UIViewController() - if let wv = webView { - vc.view = wv - } + let vc = WebViewHostController(webView: webView) self.viewController = vc return vc } } +// MARK: - Host VC that embeds the WKWebView with proper Auto Layout + +private class WebViewHostController: UIViewController { + private let embeddedWebView: WKWebView? + + init(webView: WKWebView?) { + self.embeddedWebView = webView + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + guard let wv = embeddedWebView else { return } + wv.translatesAutoresizingMaskIntoConstraints = false + wv.scrollView.contentInsetAdjustmentBehavior = .never + view.addSubview(wv) + NSLayoutConstraint.activate([ + wv.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + wv.bottomAnchor.constraint(equalTo: view.bottomAnchor), + wv.leadingAnchor.constraint(equalTo: view.leadingAnchor), + wv.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + } +} + +// MARK: - WKNavigationDelegate + +extension WebViewProviderImpl: WKNavigationDelegate { + public func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url, let host = url.host else { + decisionHandler(.cancel) + return + } + let isTrusted = + (url.scheme == "https" && host == "self-app-alpha.vercel.app") || + (isDebugMode && url.scheme == "http" && host == "127.0.0.1") + decisionHandler(isTrusted ? .allow : .cancel) + } +} + // MARK: - WKScriptMessageHandler extension WebViewProviderImpl: WKScriptMessageHandler { diff --git a/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx b/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx index 28c19261d..8d9468d42 100644 --- a/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx +++ b/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx @@ -161,6 +161,35 @@ export const KeychainDebugScreen: React.FC = () => { } }, [storage, addLog]); + const handleDumpAll = useCallback(async () => { + const ALL_KEYS = ['self_document_catalog', 'self_mnemonic', 'self_private_key']; + try { + for (const k of ALL_KEYS) { + const val = await storage.get(k); + if (val === null) { + addLog(`${k} -> null`); + } else if (k === 'self_document_catalog') { + try { + const catalog = JSON.parse(val); + const docs = catalog.documents ?? []; + addLog(`${k} -> ${docs.length} doc(s), selected: ${catalog.selectedDocumentId ?? 'none'}`); + for (const doc of docs) { + addLog(` [${doc.id}] type=${doc.documentType} mock=${doc.mock} registered=${doc.isRegistered}`); + } + } catch { + addLog(`${k} -> ${val.length} chars (parse error)`, true); + } + } else if (k === 'self_mnemonic') { + addLog(`${k} -> ${val.split(' ').length} words`); + } else { + addLog(`${k} -> ${val.slice(0, 20)}... (${val.length} chars)`); + } + } + } catch (e) { + addLog(`DUMP FAILED: ${e}`, true); + } + }, [storage, addLog]); + const handleDeleteDoc = useCallback(async () => { try { await documents.deleteDocument(docId); @@ -177,80 +206,85 @@ export const KeychainDebugScreen: React.FC = () => { return (
-
- -

Keychain Debug

- - {bridge.isConnected ? 'Bridge connected' : 'No transport'} - -
+
+
+ +

Keychain Debug

+ + {bridge.isConnected ? 'Bridge connected' : 'No transport'} + +
-
-
- +
+
+ +
-
-
-

Raw Secure Storage

-
- setKey(e.target.value)} /> - setValue(e.target.value)} /> +
+

Raw Secure Storage

+
+ setKey(e.target.value)} /> + setValue(e.target.value)} /> +
+
+ + + +
-
- - - -
-
-
-

Documents Adapter

-
- setDocId(e.target.value)} - /> +
+

Documents Adapter

+
+ setDocId(e.target.value)} + /> +
+
+ + + +
+
+ + +
-
- - - -
-
- - -
-
-
-

Danger Zone

-
- +
+

Inspect / Danger Zone

+
+ + +
@@ -282,7 +316,15 @@ const styles: Record = { margin: '0 auto', color: '#e0e0e0', backgroundColor: '#1a1a2e', - minHeight: '100vh', + height: '100vh', + display: 'flex', + flexDirection: 'column' as const, + overflow: 'hidden', + }, + controls: { + flexShrink: 1, + overflowY: 'auto' as const, + maxHeight: '50%', }, header: { display: 'flex', @@ -347,6 +389,10 @@ const styles: Record = { }, logSection: { flex: 1, + display: 'flex', + flexDirection: 'column' as const, + minHeight: 0, + overflow: 'hidden', }, logHeader: { display: 'flex', @@ -367,7 +413,7 @@ const styles: Record = { backgroundColor: '#0a0a1a', borderRadius: 8, padding: 10, - maxHeight: 300, + flex: 1, overflowY: 'auto' as const, fontSize: 12, fontFamily: 'monospace',