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',