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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Justin Hernandez <justin.hernandez@self.xyz>
This commit is contained in:
Seshanth.S
2026-04-04 00:15:07 +05:30
committed by GitHub
parent 16ba8f1937
commit 37b8f0ca71
42 changed files with 1194 additions and 792 deletions

View File

@@ -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 {

View File

@@ -1,11 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".SelfTestApplication"

View File

@@ -1,45 +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.runtime.Composable
import androidx.navigation.NavController
import xyz.self.testapp.viewmodels.VerificationViewModel
/**
* Android implementation: Forward to the actual screen implementation
*/
@Composable
actual fun MrzScanScreen(
navController: NavController,
viewModel: VerificationViewModel,
) {
xyz.self.testapp.screens
.MrzScanScreen(navController, viewModel)
}
/**
* Android implementation: Use the shared commonMain implementation
*/
@Composable
actual fun MrzConfirmationScreen(
navController: NavController,
viewModel: VerificationViewModel,
) {
xyz.self.testapp.screens
.MrzConfirmationScreen(navController, viewModel)
}
/**
* Android implementation: Forward to the actual screen implementation
*/
@Composable
actual fun NfcScanScreen(
navController: NavController,
viewModel: VerificationViewModel,
) {
xyz.self.testapp.screens
.NfcScanScreen(navController, viewModel)
}

View File

@@ -9,10 +9,22 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import xyz.self.sdk.api.SelfSdk
import xyz.self.sdk.providers.AndroidKeystoreCryptoProvider
import xyz.self.sdk.providers.EncryptedSharedPreferencesProvider
import xyz.self.sdk.providers.SdkProviderRegistry
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Register default providers so DomainSmokeScreen can test them directly
if (SdkProviderRegistry.secureStorage == null) {
SdkProviderRegistry.secureStorage = EncryptedSharedPreferencesProvider(this)
}
if (SdkProviderRegistry.crypto == null) {
SdkProviderRegistry.crypto = AndroidKeystoreCryptoProvider()
}
SelfSdk.bindActivity(this)
enableEdgeToEdge()
setContent {

View File

@@ -5,21 +5,17 @@
package xyz.self.testapp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import xyz.self.testapp.screens.PassportDetailsScreen
import xyz.self.testapp.screens.ResultScreen
import xyz.self.testapp.screens.DomainSmokeScreen
import xyz.self.testapp.screens.SdkLaunchScreen
import xyz.self.testapp.theme.SelfTestTheme
import xyz.self.testapp.viewmodels.VerificationViewModel
@Composable
fun App() {
SelfTestTheme {
val navController = rememberNavController()
val viewModel = remember { VerificationViewModel() }
NavHost(
navController = navController,
@@ -29,52 +25,9 @@ fun App() {
SdkLaunchScreen(navController)
}
composable("passport_details") {
PassportDetailsScreen(navController, viewModel)
}
composable("mrz_scan") {
MrzScanScreen(navController, viewModel)
}
composable("mrz_confirmation") {
MrzConfirmationScreen(navController, viewModel)
}
composable("nfc_scan") {
NfcScanScreen(navController, viewModel)
}
composable("result") {
ResultScreen(navController, viewModel)
composable("domain_smoke") {
DomainSmokeScreen(navController)
}
}
}
}
/**
* Platform-specific MRZ scan screen
*/
@Composable
expect fun MrzScanScreen(
navController: androidx.navigation.NavController,
viewModel: VerificationViewModel,
)
/**
* Platform-specific MRZ confirmation screen
*/
@Composable
expect fun MrzConfirmationScreen(
navController: androidx.navigation.NavController,
viewModel: VerificationViewModel,
)
/**
* Platform-specific NFC scan screen
*/
@Composable
expect fun NfcScanScreen(
navController: androidx.navigation.NavController,
viewModel: VerificationViewModel,
)

View File

@@ -0,0 +1,219 @@
// 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.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import kotlinx.coroutines.launch
import xyz.self.sdk.providers.SdkProviderRegistry
enum class CheckStatus { PENDING, PASS, FAIL }
data class SmokeResult(
val status: CheckStatus = CheckStatus.PENDING,
val detail: String = "",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DomainSmokeScreen(navController: NavController) {
var storageResult by remember { mutableStateOf(SmokeResult()) }
var cryptoResult by remember { mutableStateOf(SmokeResult()) }
var running by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Scaffold(
topBar = { TopAppBar(title = { Text("3-Domain Smoke Test") }) },
) { paddingValues ->
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}")
}
}

View File

@@ -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<String?>(null) }
var callbackError by remember { mutableStateOf<SelfSdkError?>(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))

View File

@@ -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)
}

View File

@@ -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 }
}

View File

@@ -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()
}
}

View File

@@ -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";
};

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B10000050000000000000001"
BuildableName = "iosApp.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B10000050000000000000001"
BuildableName = "iosApp.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B10000050000000000000001"
BuildableName = "iosApp.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -2,23 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
@@ -27,27 +11,18 @@
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>NFCReaderUsageDescription</key>
<string>This app needs access to NFC to read your passport for identity verification.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
<string>A0000002471001</string>
<string>A0000002472001</string>
<string>00000000000000</string>
</array>
<key>NSCameraUsageDescription</key>
<string>This app needs access to your camera to scan the MRZ code on your passport.</string>
</dict>
</plist>

View File

@@ -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 {

View File

@@ -1,11 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>NDEF</string>
<string>TAG</string>
</array>
</dict>
<dict/>
</plist>

View File

@@ -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")
}
}
}

View File

@@ -1,25 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- NFC Permissions -->
<uses-permission android:name="android.permission.NFC" />
<!-- NFC Feature (required=false allows installation on devices without NFC) -->
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<!-- Vibration for haptic feedback -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Camera for MRZ scanning -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<!-- Internet for API calls (if needed) -->
<!-- Internet for WebView content loading -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application>
<!-- SelfVerificationActivity -->

View File

@@ -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<String, JsonElement>,
): JsonElement? =
when (method) {
"get" -> get(params)
"set" -> set(params)
"remove" -> remove(params)
"clear" -> clear()
else -> throw BridgeHandlerException(
"METHOD_NOT_FOUND",
"Unknown secureStorage method: $method",
)
}
/**
* 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<String, JsonElement>): 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<String, JsonElement>): 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<String, JsonElement>): 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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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<Array<Uri>>? = 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<String>()
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<Array<Uri>>?,
fileChooserParams: FileChooserParams?,
): Boolean {
fileUploadCallback?.onReceiveValue(null)
fileUploadCallback = filePathCallback
val intent = fileChooserParams?.createIntent() ?: return false
val activity =
context as? Activity ?: run {
fileUploadCallback = null
return false
}
try {
@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
}
}

View File

@@ -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<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == AndroidWebViewHost.CAMERA_PERMISSION_REQUEST_CODE) {
val pending = webViewHost.pendingPermissionRequest ?: return
if (grantResults.isNotEmpty() && grantResults.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"

View File

@@ -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,
)

View File

@@ -11,4 +11,10 @@ data class VerificationRequest(
val userId: String? = null,
val scope: String? = null,
val disclosures: List<String> = emptyList(),
val verificationId: String? = null,
val resultType: String? = null,
val excludedCountries: List<String> = emptyList(),
val userIdType: String? = null,
val userDefinedData: String? = null,
val selfDefinedData: String? = null,
)

View File

@@ -17,7 +17,11 @@ class MessageRouter(
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
private val handlers = mutableMapOf<BridgeDomain, BridgeHandler>()
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(

View File

@@ -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<String, JsonElement>): JsonElement? {
val provider =
SdkProviderRegistry.secureStorage
?: throw BridgeHandlerException("NOT_CONFIGURED", "SecureStorage provider not configured")
val key =
params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
val value =
params["value"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_VALUE", "Value parameter required")
provider.set(key, value)
return null
}
@@ -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
}

View File

@@ -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
}
}

View File

@@ -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<String>()
// 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")
}

View File

@@ -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<String, JsonElement>): 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())
}
}

View File

@@ -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
}
}

View File

@@ -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<String, JsonElement>): 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<String, JsonElement>): 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<String, JsonElement>): 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<String, JsonElement>): JsonElement? {
val provider =
SdkProviderRegistry.documents
IosProviderRegistry.documents
?: throw BridgeHandlerException("NOT_CONFIGURED", "Documents provider not configured")
val id =

View File

@@ -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<String, JsonElement>): 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())
}
}

View File

@@ -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<String, JsonElement>): 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())
}
}

View File

@@ -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
}
}

View File

@@ -13,6 +13,7 @@ interface WebViewProvider {
fun createWebView(
onMessageReceived: (String) -> Unit,
isDebugMode: Boolean,
queryParams: String? = null,
): UIView
fun evaluateJs(js: String)

View File

@@ -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()
}

View File

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

View File

@@ -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 {

View File

@@ -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 (
<div style={styles.container}>
<div style={styles.header}>
<button style={styles.backButton} onClick={() => navigate('/settings/dev-mode')}>
&larr; Back
</button>
<h2 style={styles.title}>Keychain Debug</h2>
<span style={{ marginLeft: 'auto', fontSize: 12, color: bridge.isConnected ? '#a0e0a0' : '#e94560' }}>
{bridge.isConnected ? 'Bridge connected' : 'No transport'}
</span>
</div>
<div style={styles.controls}>
<div style={styles.header}>
<button style={styles.backButton} onClick={() => navigate('/settings/dev-mode')}>
&larr; Back
</button>
<h2 style={styles.title}>Keychain Debug</h2>
<span style={{ marginLeft: 'auto', fontSize: 12, color: bridge.isConnected ? '#a0e0a0' : '#e94560' }}>
{bridge.isConnected ? 'Bridge connected' : 'No transport'}
</span>
</div>
<div style={styles.section}>
<div style={styles.row}>
<button style={{ ...styles.button, backgroundColor: '#e0a030' }} onClick={handlePing}>
Ping Bridge (5s timeout)
</button>
<div style={styles.section}>
<div style={styles.row}>
<button style={{ ...styles.button, backgroundColor: '#e0a030' }} onClick={handlePing}>
Ping Bridge (5s timeout)
</button>
</div>
</div>
</div>
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Raw Secure Storage</h3>
<div style={styles.row}>
<input style={styles.input} placeholder="Key" value={key} onChange={e => setKey(e.target.value)} />
<input style={styles.input} placeholder="Value" value={value} onChange={e => setValue(e.target.value)} />
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Raw Secure Storage</h3>
<div style={styles.row}>
<input style={styles.input} placeholder="Key" value={key} onChange={e => setKey(e.target.value)} />
<input style={styles.input} placeholder="Value" value={value} onChange={e => setValue(e.target.value)} />
</div>
<div style={styles.row}>
<button style={styles.button} onClick={handleSet}>
Set
</button>
<button style={styles.button} onClick={handleGet}>
Get
</button>
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleRemove}>
Remove
</button>
</div>
</div>
<div style={styles.row}>
<button style={styles.button} onClick={handleSet}>
Set
</button>
<button style={styles.button} onClick={handleGet}>
Get
</button>
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleRemove}>
Remove
</button>
</div>
</div>
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Documents Adapter</h3>
<div style={styles.row}>
<input
style={styles.input}
placeholder="Document ID"
value={docId}
onChange={e => setDocId(e.target.value)}
/>
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Documents Adapter</h3>
<div style={styles.row}>
<input
style={styles.input}
placeholder="Document ID"
value={docId}
onChange={e => setDocId(e.target.value)}
/>
</div>
<div style={styles.row}>
<button style={styles.button} onClick={handleSaveDoc}>
Save Mock
</button>
<button style={styles.button} onClick={handleLoadDoc}>
Load
</button>
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleDeleteDoc}>
Delete
</button>
</div>
<div style={styles.row}>
<button style={styles.button} onClick={handleLoadCatalog}>
Load Catalog
</button>
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleDeleteCatalog}>
Delete Catalog
</button>
</div>
</div>
<div style={styles.row}>
<button style={styles.button} onClick={handleSaveDoc}>
Save Mock
</button>
<button style={styles.button} onClick={handleLoadDoc}>
Load
</button>
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleDeleteDoc}>
Delete
</button>
</div>
<div style={styles.row}>
<button style={styles.button} onClick={handleLoadCatalog}>
Load Catalog
</button>
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleDeleteCatalog}>
Delete Catalog
</button>
</div>
</div>
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Danger Zone</h3>
<div style={styles.row}>
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleClearAll}>
Clear Entire Keychain
</button>
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Inspect / Danger Zone</h3>
<div style={styles.row}>
<button style={{ ...styles.button, backgroundColor: '#2563eb' }} onClick={handleDumpAll}>
Dump All Keys
</button>
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleClearAll}>
Clear Entire Keychain
</button>
</div>
</div>
</div>
@@ -282,7 +316,15 @@ const styles: Record<string, React.CSSProperties> = {
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<string, React.CSSProperties> = {
},
logSection: {
flex: 1,
display: 'flex',
flexDirection: 'column' as const,
minHeight: 0,
overflow: 'hidden',
},
logHeader: {
display: 'flex',
@@ -367,7 +413,7 @@ const styles: Record<string, React.CSSProperties> = {
backgroundColor: '#0a0a1a',
borderRadius: 8,
padding: 10,
maxHeight: 300,
flex: 1,
overflowY: 'auto' as const,
fontSize: 12,
fontFamily: 'monospace',