mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
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:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface WebViewProvider {
|
||||
fun createWebView(
|
||||
onMessageReceived: (String) -> Unit,
|
||||
isDebugMode: Boolean,
|
||||
queryParams: String? = null,
|
||||
): UIView
|
||||
|
||||
fun evaluateJs(js: String)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')}>
|
||||
← 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')}>
|
||||
← 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',
|
||||
|
||||
Reference in New Issue
Block a user