Wire WebView camera adapter and harden Android SDK launch flow (#1805)

* save chunk 1e work

* chunk 2f

* pr feedback

* fix pr feedback

* cr feedback

* remove dupe var

* feedback from cr

* add kmp:start dx helper

* save web consolidtion work for later

* add specs

* update

* pr fixes

* update reorg plan
This commit is contained in:
Justin Hernandez
2026-03-05 12:14:02 -08:00
committed by GitHub
parent ab5f584210
commit 44ded24886
28 changed files with 1433 additions and 186 deletions

View File

@@ -11,4 +11,3 @@ MIXPANEL_NFC_PROJECT_TOKEN=
SEGMENT_KEY=
SENTRY_DSN=
SUMSUB_TEE_URL=
IS_TEST_BUILD=

View File

@@ -33,6 +33,7 @@
"kmp:format": "yarn workspace @selfxyz/kmp-test-app format",
"kmp:ios": "yarn workspace @selfxyz/kmp-test-app ios:open",
"kmp:lint": "yarn workspace @selfxyz/kmp-test-app lint",
"kmp:start": "bash scripts/kmp-start.sh",
"kmp:test": "yarn workspace @selfxyz/kmp-sdk test",
"lint": "yarn lint:headers && yarn workspaces foreach --parallel -i --all --exclude self-workspace-root run lint",
"lint:headers": "node scripts/check-duplicate-headers.cjs . && node scripts/check-license-headers.mjs . --check",

View File

@@ -4,13 +4,16 @@
package xyz.self.sdk.api
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.serialization.json.Json
import xyz.self.sdk.webview.SelfVerificationActivity
import java.lang.ref.WeakReference
/**
* Android implementation of the Self SDK.
@@ -20,44 +23,65 @@ actual class SelfSdk private constructor(
private val config: SelfSdkConfig,
) {
private var activityLauncher: ActivityResultLauncher<Intent>? = null
private var launcherOwner: WeakReference<ComponentActivity>? = null
private var boundActivity: WeakReference<ComponentActivity>? = null
private var pendingCallback: SelfSdkCallback? = null
private var lifecycleObserver: DefaultLifecycleObserver? = null
private var observerActivity: WeakReference<ComponentActivity>? = null
actual companion object {
private var instance: SelfSdk? = null
private var configuredWith: SelfSdkConfig? = null
private var currentActivity: WeakReference<ComponentActivity>? = null
/**
* Configures and returns a singleton SelfSdk instance.
*/
actual fun configure(config: SelfSdkConfig): SelfSdk {
if (instance == null) {
if (instance == null || configuredWith != config) {
instance?.cleanup()
instance = SelfSdk(config)
configuredWith = config
}
val activity = currentActivity?.get()
if (activity != null) {
instance?.bindActivity(activity)
}
return instance!!
}
/**
* Binds the currently active host Activity so common launch(request, callback)
* can work without Android-specific overloads.
*/
fun bindActivity(activity: ComponentActivity) {
currentActivity = WeakReference(activity)
instance?.bindActivity(activity)
}
}
/**
* Launches the verification flow.
* The calling Activity must be a ComponentActivity for result handling.
*
* Note: For production use, the host app should register the ActivityResultLauncher
* in onCreate() and pass it to this method, rather than registering it here.
* This implementation is simplified for the initial version.
* Launches the verification flow through the common API surface.
* On Android, this requires a bound ComponentActivity via SelfSdk.bindActivity(activity).
*/
actual fun launch(
request: VerificationRequest,
callback: SelfSdkCallback,
) {
// Store callback for later
pendingCallback = callback
// Get current activity context
// Note: In production, the host app should pass the activity explicitly
// For now, we'll require the activity to be passed via a helper method
throw NotImplementedError(
"Please use launch(activity, request, callback) instead. " +
"The Activity parameter is required on Android.",
)
val activity =
resolveActivity()
?: run {
callback.onFailure(
SelfSdkError(
code = "MISSING_ACTIVITY",
message =
"No bound ComponentActivity found. " +
"Call SelfSdk.bindActivity(activity) in your Activity before launch().",
),
)
return
}
launchInternal(activity, request, callback)
}
/**
@@ -73,6 +97,28 @@ actual class SelfSdk private constructor(
request: VerificationRequest,
callback: SelfSdkCallback,
) {
bindActivity(activity)
Companion.currentActivity = WeakReference(activity)
launchInternal(activity, request, callback)
}
private fun launchInternal(
activity: ComponentActivity,
request: VerificationRequest,
callback: SelfSdkCallback,
) {
if (pendingCallback != null) {
callback.onFailure(
SelfSdkError(
code = "VERIFICATION_IN_PROGRESS",
message = "A verification flow is already in progress",
),
)
return
}
pendingCallback = callback
// Create intent for SelfVerificationActivity
val intent =
Intent(activity, SelfVerificationActivity::class.java).apply {
@@ -81,21 +127,120 @@ actual class SelfSdk private constructor(
putExtra(SelfVerificationActivity.EXTRA_CONFIG, serializeConfig(config))
}
// Register using the ActivityResultRegistry directly (without LifecycleOwner)
// so it can be called after onStart(). The host app's Activity may already
// be in RESUMED state when the user taps "verify".
if (activityLauncher == null) {
activityLauncher =
activity.activityResultRegistry.register(
"self-sdk-verification",
ActivityResultContracts.StartActivityForResult(),
) { result ->
handleActivityResult(result.resultCode, result.data, callback)
}
// Launch the verification activity
val launcher = activityLauncher
if (launcher == null) {
pendingCallback = null
callback.onFailure(
SelfSdkError(
code = "LAUNCHER_NOT_AVAILABLE",
message = "Could not initialize Android activity launcher",
),
)
return
}
try {
launcher.launch(intent)
} catch (e: ActivityNotFoundException) {
pendingCallback = null
callback.onFailure(
SelfSdkError(
code = "ACTIVITY_NOT_FOUND",
message = "Could not launch verification activity: ${e.message}",
),
)
} catch (e: IllegalStateException) {
pendingCallback = null
callback.onFailure(
SelfSdkError(
code = "LAUNCH_FAILED",
message = "Could not launch verification activity: ${e.message}",
),
)
}
}
private fun bindActivity(activity: ComponentActivity) {
boundActivity = WeakReference(activity)
ensureLauncher(activity)
if (observerActivity?.get() === activity) {
return
}
// Launch the verification activity
activityLauncher?.launch(intent)
val previousActivity = observerActivity?.get()
val previousObserver = lifecycleObserver
if (previousActivity != null && previousObserver != null) {
previousActivity.lifecycle.removeObserver(previousObserver)
}
val observer =
object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
if (launcherOwner?.get() === activity) {
activityLauncher?.unregister()
activityLauncher = null
launcherOwner = null
}
if (boundActivity?.get() === activity) {
boundActivity = null
}
pendingCallback?.onCancelled()
pendingCallback = null
lifecycleObserver = null
observerActivity = null
}
}
lifecycleObserver = observer
observerActivity = WeakReference(activity)
activity.lifecycle.addObserver(observer)
}
private fun cleanup() {
activityLauncher?.unregister()
activityLauncher = null
launcherOwner = null
val activity = observerActivity?.get()
val observer = lifecycleObserver
if (activity != null && observer != null) {
activity.lifecycle.removeObserver(observer)
}
lifecycleObserver = null
observerActivity = null
boundActivity = null
pendingCallback?.onCancelled()
pendingCallback = null
}
private fun resolveActivity(): ComponentActivity? {
val resolved = boundActivity?.get() ?: Companion.currentActivity?.get()
if (resolved != null) {
bindActivity(resolved)
}
return resolved
}
private fun ensureLauncher(activity: ComponentActivity) {
val currentOwner = launcherOwner?.get()
if (activityLauncher != null && currentOwner === activity) {
return
}
activityLauncher?.unregister()
launcherOwner = WeakReference(activity)
activityLauncher =
activity.activityResultRegistry.register(
"self-sdk-verification",
ActivityResultContracts.StartActivityForResult(),
) { result ->
val callback = pendingCallback
pendingCallback = null
if (callback != null) {
handleActivityResult(result.resultCode, result.data, callback)
}
}
}
/**
@@ -107,7 +252,7 @@ actual class SelfSdk private constructor(
callback: SelfSdkCallback,
) {
when (resultCode) {
Activity.RESULT_OK -> {
SelfVerificationActivity.RESULT_CODE_SUCCESS -> {
val resultDataJson = data?.getStringExtra(SelfVerificationActivity.EXTRA_RESULT_DATA)
val resultType = data?.getStringExtra(SelfVerificationActivity.EXTRA_RESULT_TYPE)
if (resultDataJson != null) {
@@ -135,7 +280,7 @@ actual class SelfSdk private constructor(
)
}
}
Activity.RESULT_CANCELED -> {
SelfVerificationActivity.RESULT_CODE_CANCELLED -> {
// User cancelled
callback.onCancelled()
}

View File

@@ -51,7 +51,7 @@ class LifecycleBridgeHandler(
*/
private fun dismiss(): JsonElement? {
activity.runOnUiThread {
activity.setResult(Activity.RESULT_CANCELED)
activity.setResult(SelfVerificationActivity.RESULT_CODE_CANCELLED)
activity.finish()
}
return null
@@ -74,19 +74,19 @@ class LifecycleBridgeHandler(
if (type != null) {
// Flat lifecycle payload (e.g. { type: "proofRequested" }) — treat as success
intent.putExtra(SelfVerificationActivity.EXTRA_RESULT_TYPE, type)
activity.setResult(Activity.RESULT_OK, intent)
activity.setResult(SelfVerificationActivity.RESULT_CODE_SUCCESS, intent)
} else if (success && data != null) {
// Success result
intent.putExtra(SelfVerificationActivity.EXTRA_RESULT_DATA, data)
activity.setResult(Activity.RESULT_OK, intent)
activity.setResult(SelfVerificationActivity.RESULT_CODE_SUCCESS, intent)
} else if (!success && errorCode != null) {
// Error result
intent.putExtra(SelfVerificationActivity.EXTRA_ERROR_CODE, errorCode)
intent.putExtra(SelfVerificationActivity.EXTRA_ERROR_MESSAGE, errorMessage ?: "Unknown error")
activity.setResult(Activity.RESULT_FIRST_USER, intent)
activity.setResult(SelfVerificationActivity.RESULT_CODE_ERROR, intent)
} else {
// Cancelled or invalid result
activity.setResult(Activity.RESULT_CANCELED, intent)
activity.setResult(SelfVerificationActivity.RESULT_CODE_CANCELLED, intent)
}
activity.finish()

View File

@@ -108,7 +108,7 @@ class AndroidWebViewHost(
val url = request?.url?.toString() ?: return true
val assetHost = "https://appassets.androidplatform.net/"
if (url.startsWith(assetHost)) return false
if (isDebugMode && url.startsWith("http://10.0.2.2:5173")) return false
if (isDebugMode && url.startsWith("http://127.0.0.1:5173")) return false
return true // block everything else
}
@@ -128,8 +128,8 @@ class AndroidWebViewHost(
// Load appropriate URL based on mode
if (isDebugMode) {
// Development mode: connect to Vite dev server
// Android emulator uses 10.0.2.2 to access host machine's localhost
loadUrl("http://10.0.2.2:5173")
// 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,

View File

@@ -8,6 +8,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.self.sdk.api.SelfSdkConfig
import xyz.self.sdk.api.VerificationRequest
import xyz.self.sdk.api.VerificationResult
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -122,6 +123,22 @@ class ModelSerializationTest {
assertEquals(request, decoded)
}
@Test
fun verificationResult_roundtrip() {
val result =
VerificationResult(
success = true,
type = "proofGenerated",
userId = "user-1",
verificationId = "verification-123",
proof = "proof-bytes",
claims = mapOf("nationality" to "UTO"),
)
val encoded = json.encodeToString(result)
val decoded = json.decodeFromString<VerificationResult>(encoded)
assertEquals(result, decoded)
}
@Test
fun selfSdkConfig_defaults() {
val config = SelfSdkConfig()

View File

@@ -44,10 +44,12 @@ actual class SelfSdk private constructor(
actual companion object {
private var instance: SelfSdk? = null
private var configuredWith: SelfSdkConfig? = null
actual fun configure(config: SelfSdkConfig): SelfSdk {
if (instance == null) {
if (instance == null || configuredWith != config) {
instance = SelfSdk(config)
configuredWith = config
}
return instance!!
}

View File

@@ -8,10 +8,12 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import xyz.self.sdk.api.SelfSdk
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SelfSdk.bindActivity(this)
enableEdgeToEdge()
setContent {
App()

View File

@@ -11,6 +11,7 @@ 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.SdkLaunchScreen
import xyz.self.testapp.theme.SelfTestTheme
import xyz.self.testapp.viewmodels.VerificationViewModel
@@ -22,8 +23,12 @@ fun App() {
NavHost(
navController = navController,
startDestination = "passport_details",
startDestination = "sdk_launch",
) {
composable("sdk_launch") {
SdkLaunchScreen(navController)
}
composable("passport_details") {
PassportDetailsScreen(navController, viewModel)
}

View File

@@ -0,0 +1,180 @@
// 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.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.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.self.sdk.api.SelfSdk
import xyz.self.sdk.api.SelfSdkCallback
import xyz.self.sdk.api.SelfSdkConfig
import xyz.self.sdk.api.SelfSdkError
import xyz.self.sdk.api.VerificationRequest
import xyz.self.sdk.api.VerificationResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SdkLaunchScreen(navController: NavController) {
var userId by remember { mutableStateOf("test-user") }
var scope by remember { mutableStateOf("identity") }
var callbackStatus by remember { mutableStateOf("Idle") }
var callbackPayload by remember { mutableStateOf<String?>(null) }
var callbackError by remember { mutableStateOf<SelfSdkError?>(null) }
val coroutineScope = rememberCoroutineScope()
val sdk = remember { SelfSdk.configure(SelfSdkConfig(debug = true)) }
val json = remember { Json { prettyPrint = true } }
Scaffold(
topBar = { TopAppBar(title = { Text("SDK Public API Test") }) },
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "This button validates SelfSdk.configure(...).launch(...) end-to-end.",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedTextField(
value = userId,
onValueChange = { userId = it },
label = { Text("User ID") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = scope,
onValueChange = { scope = it },
label = { Text("Scope") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = {
callbackStatus = "Launching verification..."
callbackPayload = null
callbackError = null
val request =
VerificationRequest(
userId = userId.ifBlank { null },
scope = scope.ifBlank { null },
disclosures = listOf("name", "nationality", "date_of_birth"),
)
sdk.launch(
request = request,
callback =
object : SelfSdkCallback {
override fun onSuccess(result: VerificationResult) {
coroutineScope.launch {
callbackStatus = "Success"
callbackError = null
callbackPayload =
json.encodeToString(
VerificationResult.serializer(),
result,
)
}
}
override fun onFailure(error: SelfSdkError) {
coroutineScope.launch {
callbackStatus = "Failure"
callbackError = error
callbackPayload = null
}
}
override fun onCancelled() {
coroutineScope.launch {
callbackStatus = "Cancelled"
callbackError = null
callbackPayload = null
}
}
},
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Launch Verification")
}
OutlinedButton(
onClick = { navController.navigate("passport_details") },
modifier = Modifier.fillMaxWidth(),
) {
Text("Open Manual MRZ/NFC Flow")
}
Spacer(modifier = Modifier.height(8.dp))
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Callback Status: $callbackStatus")
if (callbackError != null) {
Text("Error Code: ${callbackError?.code}")
Text("Error Message: ${callbackError?.message}")
}
if (callbackPayload != null) {
Text(
text = callbackPayload!!,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" />
</manifest>

View File

@@ -3,16 +3,20 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useMemo, useState } from 'react';
import { NativeModules, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
NativeModules,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { SelfVerification, type SelfSdkError, type VerificationResult } from '@selfxyz/rn-sdk';
const defaultRequest = {
userId: 'rn-test-user',
scope: 'rn-sdk-test',
disclosures: [],
};
const fallbackMrzScannerModule = {
startScanning: async () => ({
documentNumber: 'XK0000000',
@@ -38,8 +42,6 @@ function ensureMrzScannerModule(): void {
typeof legacyScanner?.startScanning === 'function';
if (!hasScanner) {
// Keep camera bridge round-trip testable in this harness when host-native MRZ isn't wired.
// Hermes NativeModules host object can reject writes, so this fallback is best-effort.
try {
nativeModules.SelfMRZScannerModule = fallbackMrzScannerModule;
} catch {
@@ -50,24 +52,47 @@ function ensureMrzScannerModule(): void {
ensureMrzScannerModule();
type CallbackState =
| { status: 'Idle' }
| { status: 'Launching verification...' }
| { status: 'Success'; payload: string }
| { status: 'Failure'; code: string; message: string }
| { status: 'Cancelled' };
function App(): React.JSX.Element {
const [isVerifying, setIsVerifying] = useState(false);
const [status, setStatus] = useState('Ready');
const [userId, setUserId] = useState('test-user');
const [scope, setScope] = useState('identity');
const [callback, setCallback] = useState<CallbackState>({ status: 'Idle' });
const request = useMemo(() => defaultRequest, []);
const request = useMemo(
() => ({
userId: userId || undefined,
scope: scope || undefined,
disclosures: ['name', 'nationality', 'date_of_birth'],
}),
[userId, scope],
);
const handleSuccess = (result: VerificationResult) => {
setStatus(`Success: ${result.verificationId ?? 'no verificationId'}`);
setCallback({
status: 'Success',
payload: JSON.stringify(result, null, 2),
});
setIsVerifying(false);
};
const handleFailure = (error: SelfSdkError) => {
setStatus(`Failure: ${error.code} - ${error.message}`);
setCallback({
status: 'Failure',
code: error.code,
message: error.message,
});
setIsVerifying(false);
};
const handleCancelled = () => {
setStatus('Cancelled');
setCallback({ status: 'Cancelled' });
setIsVerifying(false);
};
@@ -90,13 +115,57 @@ function App(): React.JSX.Element {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" />
<View style={styles.content}>
<Text style={styles.title}>RN SDK Test Harness</Text>
<Text style={styles.subtitle}>Status: {status}</Text>
<TouchableOpacity style={styles.button} onPress={() => setIsVerifying(true)}>
<Text style={styles.buttonText}>Launch Verification</Text>
</TouchableOpacity>
<View style={styles.topBar}>
<Text style={styles.topBarTitle}>SDK Public API Test</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.description}>
This button validates SelfSdk.configure(...).launch(...) end-to-end.
</Text>
<Text style={styles.label}>User ID</Text>
<TextInput
style={styles.input}
value={userId}
onChangeText={setUserId}
placeholder="test-user"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Scope</Text>
<TextInput
style={styles.input}
value={scope}
onChangeText={setScope}
placeholder="identity"
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={styles.primaryButton}
onPress={() => {
setCallback({ status: 'Launching verification...' });
setIsVerifying(true);
}}
>
<Text style={styles.primaryButtonText}>Launch Verification</Text>
</TouchableOpacity>
<View style={styles.callbackCard}>
<Text style={styles.callbackLabel}>Callback Status: {callback.status}</Text>
{callback.status === 'Failure' && (
<>
<Text style={styles.callbackDetail}>Error Code: {callback.code}</Text>
<Text style={styles.callbackDetail}>Error Message: {callback.message}</Text>
</>
)}
{callback.status === 'Success' && (
<Text style={styles.callbackPayload}>{callback.payload}</Text>
)}
</View>
</ScrollView>
</SafeAreaView>
);
}
@@ -106,34 +175,74 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#f6f7f8',
},
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
gap: 16,
topBar: {
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#f6f7f8',
},
title: {
fontSize: 24,
topBarTitle: {
fontSize: 22,
fontWeight: '700',
color: '#111827',
},
subtitle: {
content: {
paddingHorizontal: 16,
paddingBottom: 32,
gap: 12,
},
description: {
fontSize: 14,
color: '#374151',
textAlign: 'center',
lineHeight: 20,
},
button: {
label: {
fontSize: 12,
fontWeight: '500',
color: '#6b7280',
marginBottom: -8,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#111827',
backgroundColor: '#ffffff',
},
primaryButton: {
backgroundColor: '#111827',
borderRadius: 10,
paddingHorizontal: 20,
paddingVertical: 12,
paddingVertical: 14,
alignItems: 'center',
},
buttonText: {
primaryButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
callbackCard: {
backgroundColor: '#e8e5f0',
borderRadius: 12,
padding: 16,
gap: 6,
},
callbackLabel: {
fontSize: 14,
fontWeight: '500',
color: '#1f2937',
},
callbackDetail: {
fontSize: 13,
color: '#374151',
},
callbackPayload: {
fontSize: 12,
fontFamily: 'monospace',
color: '#374151',
lineHeight: 18,
},
verificationView: {
flex: 1,
},

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Button,
@@ -15,14 +15,16 @@ import {
spacing,
} from '@selfxyz/euclid-web';
import { useBridge } from '../../providers/BridgeProvider';
import { useSelfClient } from '../../providers/SelfClientProvider';
const GENERIC_SCAN_ERROR_MESSAGE = 'We could not read your document. Please try again.';
const CAMERA_UNAVAILABLE_MESSAGE = 'Camera is not available on this device.';
const MRZ_INVALID_DATA_ERROR = 'MRZ_INVALID_DATA';
export const DocumentCameraScreen: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const bridge = useBridge();
const { analytics, haptic } = useSelfClient();
const { analytics, haptic, camera } = useSelfClient();
const { countryCode = '', documentType = 'p' } =
(location.state as {
@@ -32,24 +34,50 @@ export const DocumentCameraScreen: React.FC = () => {
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
const scanGenerationRef = useRef(0);
const scanInFlightRef = useRef(false);
const scanPrompt =
documentType === 'i' ? 'Scan your ID card' : 'Scan your passport';
const startMRZScan = useCallback(async () => {
if (scanInFlightRef.current) return;
scanInFlightRef.current = true;
const scanGeneration = scanGenerationRef.current + 1;
scanGenerationRef.current = scanGeneration;
setScanning(true);
setError(null);
analytics.trackEvent('camera_mrz_scan_started', {
documentType,
countryCode,
});
try {
const result = await bridge.request<{
documentNumber: string;
dateOfBirth: string;
dateOfExpiry: string;
}>('camera', 'scanMRZ', { documentType, countryCode });
const available = await camera.isAvailable();
if (!mountedRef.current || scanGenerationRef.current !== scanGeneration) {
return;
}
if (!available) {
setError(CAMERA_UNAVAILABLE_MESSAGE);
analytics.trackEvent('camera_mrz_scan_failed', { errorCode: 'CAMERA_NOT_AVAILABLE' });
return;
}
analytics.trackEvent('camera_mrz_scan_started', {
documentType,
countryCode,
});
const result = await camera.scanMRZ({ documentType, countryCode });
if (!mountedRef.current || scanGenerationRef.current !== scanGeneration) {
return;
}
const passportNumber = result.documentNumber?.trim() ?? '';
const dateOfBirth = result.dateOfBirth?.trim() ?? '';
const dateOfExpiry = result.dateOfExpiry?.trim() ?? '';
if (!passportNumber || !dateOfBirth || !dateOfExpiry) {
throw new Error(MRZ_INVALID_DATA_ERROR);
}
haptic.trigger('success');
analytics.trackEvent('camera_mrz_scan_success');
@@ -58,25 +86,49 @@ export const DocumentCameraScreen: React.FC = () => {
state: {
countryCode,
documentType,
passportNumber: result.documentNumber,
dateOfBirth: result.dateOfBirth,
dateOfExpiry: result.dateOfExpiry,
passportNumber,
dateOfBirth,
dateOfExpiry,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : 'MRZ scan failed';
setError(message);
analytics.trackEvent('camera_mrz_scan_failed', { error: message });
if (!mountedRef.current || scanGenerationRef.current !== scanGeneration) {
return;
}
const bridgeErrorCode =
typeof err === 'object' &&
err !== null &&
'code' in err &&
typeof (err as { code?: unknown }).code === 'string'
? (err as { code: string }).code
: undefined;
const errorCode =
bridgeErrorCode === MRZ_INVALID_DATA_ERROR ||
(err instanceof Error && err.message === MRZ_INVALID_DATA_ERROR)
? 'MRZ_INVALID_DATA'
: 'MRZ_SCAN_FAILED';
setError(GENERIC_SCAN_ERROR_MESSAGE);
analytics.trackEvent('camera_mrz_scan_failed', { errorCode });
} finally {
setScanning(false);
scanInFlightRef.current = false;
if (mountedRef.current && scanGenerationRef.current === scanGeneration) {
setScanning(false);
}
}
}, [bridge, navigate, analytics, haptic, documentType, countryCode]);
}, [camera, navigate, analytics, haptic, documentType, countryCode]);
useEffect(() => {
mountedRef.current = true;
startMRZScan();
return () => {
mountedRef.current = false;
scanGenerationRef.current += 1;
};
}, [startMRZScan]);
const onCancel = useCallback(() => {
scanGenerationRef.current += 1;
analytics.trackEvent('camera_screen_closed');
navigate('/');
}, [navigate, analytics]);

View File

@@ -5,13 +5,19 @@
import type { WebViewBridge } from '../bridge';
export interface MrzScanResult {
documentNumber: string;
dateOfBirth: string;
dateOfExpiry: string;
documentNumber?: string;
dateOfBirth?: string;
dateOfExpiry?: string;
}
export interface MrzScanParams {
documentType?: string;
countryCode?: string;
[key: string]: unknown;
}
export interface BridgeCameraAdapter {
scanMRZ(): Promise<MrzScanResult>;
scanMRZ(params?: MrzScanParams): Promise<MrzScanResult>;
isAvailable(): Promise<boolean>;
}
@@ -19,10 +25,10 @@ export function bridgeCameraAdapter(
bridge: WebViewBridge,
): BridgeCameraAdapter {
return {
async scanMRZ(): Promise<MrzScanResult> {
async scanMRZ(params?: MrzScanParams): Promise<MrzScanResult> {
// Native handler parses the MRZ JSON string into a JsonElement,
// which arrives as an object with documentNumber, dateOfBirth, dateOfExpiry.
return bridge.request<MrzScanResult>('camera', 'scanMRZ', {});
return bridge.request<MrzScanResult>('camera', 'scanMRZ', params ?? {});
},
async isAvailable(): Promise<boolean> {

View File

@@ -36,4 +36,4 @@ export { bridgeBiometricsAdapter } from './biometrics';
export type { BridgeBiometricsAdapter } from './biometrics';
export { bridgeCameraAdapter } from './camera';
export type { BridgeCameraAdapter, MrzScanResult } from './camera';
export type { BridgeCameraAdapter, MrzScanParams, MrzScanResult } from './camera';

29
scripts/kmp-start.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
PORT=5173
if command -v adb >/dev/null 2>&1; then
adb start-server >/dev/null 2>&1 || true
device_serials="$(adb devices | awk '$2 == "device" { print $1 }')"
if [ -n "${device_serials}" ]; then
echo "Setting adb reverse tcp:${PORT} -> tcp:${PORT} for connected Android devices..."
while IFS= read -r serial; do
[ -z "${serial}" ] && continue
if adb -s "${serial}" reverse "tcp:${PORT}" "tcp:${PORT}" >/dev/null 2>&1; then
echo " ${serial}: ok"
else
echo " ${serial}: failed (continuing)"
fi
done <<< "${device_serials}"
echo "Use http://127.0.0.1:${PORT} on Android devices with adb reverse."
else
echo "No adb devices detected. If using emulator, use http://10.0.2.2:${PORT}."
fi
else
echo "adb not found. Skipping reverse setup."
fi
echo "Starting webview dev server on 0.0.0.0:${PORT}..."
exec yarn workspace @selfxyz/webview-app dev --host 0.0.0.0 --port "${PORT}"

8
specs/ARCHIVE.md Normal file
View File

@@ -0,0 +1,8 @@
# Spec Archive
Append-only log of retired specs. When a spec is fully done and no longer needed for active reference, add a row here.
For full retirement process, see [SPECS-REORG-PLAN.md](./SPECS-REORG-PLAN.md) placement rule 6.
| Spec | Retired | Outcome | Key decisions / lessons | Final PR(s) |
| ---- | ------- | ------- | ----------------------- | ----------- |

View File

@@ -0,0 +1,88 @@
# Euclid Web Consolidation Plan (Draft)
## Status
Draft and evolving. This document captures the current direction and will be refined as migration work progresses.
## Goal
Use `euclid-web` as the single source of truth for new SDK flow screens and business flow logic, while KMP/RN/mobile hosts become thin wrappers around a shared WebView flow.
## Target Architecture
1. `euclid-web` owns screen UI and flow orchestration.
2. Host apps (KMP demo, RN demo, mobile app) own only:
- WebView hosting
- bridge wiring
- platform permissions/lifecycle
- callback handoff to native caller
3. SDK surface remains stable while underlying UI/flow logic converges to web.
## Scope Boundary (Current)
In scope now:
- WebView launch reliability
- Host-to-web bridge contract
- Callback plumbing and smoke verification
Out of scope now:
- Full verification journey implementation parity in all hosts
- Consolidating/removing demo apps
- Reworking non-bridge native UI beyond what is needed for hosting
## Phased Plan
### Phase 0: Stabilize Launch (Current PR)
- Keep changes limited to launch reliability and local dev host setup.
- Ensure `kmp:start` reliably starts the web server and supports emulator/device access.
- Keep debug-only code removed unless required for ongoing validation.
### Phase 1: Shared Host Contract (Next PR)
- Define one shared config contract for URL/env/flags consumed by KMP + RN hosts.
- Define one shared event/callback contract (`success`, `error`, `cancel`, optional progress).
- Add smoke tests in each host for launch -> callback -> close path.
### Phase 2: Incremental Screen Migration
- All new flow screens land in `euclid-web` first.
- Remove duplicate native screens from demo apps as each flow area reaches parity.
- Keep demos as thin bridge/host harnesses during migration.
### Phase 3: Consolidate Demo Surface
- Decide final demo strategy once parity is high:
- single primary demo + one bridge harness, or
- both retained with explicit ownership and minimal overlap.
- De-scope duplicated flow logic from host apps.
### Phase 4: Mobile App Convergence
- Mobile app consumes the same `euclid-web` flow path as SDK demos.
- Native app keeps only host/platform concerns and app-specific shell concerns.
## Decision Rules
- If a change is launch/config/bridge related: belongs in host SDK work.
- If a change is flow UI/logic: belongs in `euclid-web`.
- If work duplicates flow code across hosts: treat as temporary and track removal.
## Open Questions
1. What is the long-term canonical demo app for partner validation?
2. What is the minimum bridge API required before flow migration accelerates?
3. Do we need a compatibility matrix per host (KMP/RN/mobile app) for each migrated flow?
4. What release gating is required before removing duplicated native flow screens?
## Proposed Near-Term PR Slices
1. Host contract + callback schema standardization.
2. RN and KMP smoke tests aligned to the same launch/callback assertions.
3. First euclid-web-only flow segment integrated into both hosts.
## Notes
This is intentionally incomplete. Add decisions and open items as migration discoveries are made.

View File

@@ -65,3 +65,7 @@ These decisions were made during this PR cycle. They are now documented in [SDK-
1. **Correctness cleanup** — Adapter consolidation, dynamic proving config, crypto adapter interface gap
2. **Publishing** — npm publish rn-sdk, finalize AAR/XCFramework packaging
3. **Self Wallet migration** — Wire `SelfVerification` into the main app (Phase 2)
## Architecture Notes
- Draft consolidation plan: [EUCLID-WEB-CONSOLIDATION.md](./EUCLID-WEB-CONSOLIDATION.md)

View File

@@ -1,62 +1,64 @@
# Self SDK — Spec System
# Specs
> Table of contents for the spec folder. Start here.
> Project-first table of contents for all specs. Start here.
## What This Is
## How Specs Are Organized
A three-tier spec system designed for parallel AI agent execution:
Specs are organized by **project** first (`kmp`, `sdk`, `lottie`, `euclid`), not by document intent.
1. **Project Overview** — architecture, contracts, cross-workstream dependencies
2. **Workstream Overviews** — orientation for each person/scope (what you own, context, status)
3. **Implementation Specs** — exact code changes, I/O examples, token-budgeted chunks
- Use `specs/projects/<project>/` for project-owned docs.
- Use `specs/framework/` for generic spec-writing rules/templates.
- Use `specs/shared/` for cross-project handoffs.
Specs double as AI agent prompts. Written in second person, sized for single context windows, with validation commands after every chunk.
## Top-Level Navigation
## Meta-Framework
- `framework/`
- `SPEC-GUIDE.md`
- `TEMPLATES.md`
- `PROJECT-RULES.md`
- `PRODUCT-SPEC-ENHANCEMENT-PROMPT.md`
| File | Purpose | When to Read |
| -------------------------------------- | ----------------------------------------------------- | --------------------------------------- |
| [SPEC-GUIDE.md](./SPEC-GUIDE.md) | How to write specs (generic, portable to any project) | Before writing or reviewing any spec |
| [TEMPLATES.md](./TEMPLATES.md) | Copy-paste templates for all three tiers | When creating a new spec |
| [PROJECT-RULES.md](./PROJECT-RULES.md) | Project-specific rules and guardrails | Before starting any implementation work |
- `projects/sdk/`
- SDK-wide architecture, wave plan, handoff, and workstreams
## Project-Level Specs
- `projects/kmp/`
- KMP initiative, architecture, status, and KMP-specific planning
| File | Purpose | When to Read |
| ------------------------------------ | ---------------------------------------------------------------------- | ------------------------------------------ |
| [SDK-OVERVIEW.md](./SDK-OVERVIEW.md) | Architecture, bridge protocol, module table, decision matrix, glossary | First. Always. |
| [WAVE-PLAN.md](./WAVE-PLAN.md) | Dependency-ordered execution plan for parallel agent work | When planning which chunks to execute next |
| [KMP-STATUS.md](./KMP-STATUS.md) | At-a-glance SDK completion percentages (web + KMP) | When you need quick status only |
- `projects/lottie/`
- Lottie migration/review specs
## Workstream Specs
- `projects/euclid/`
- Euclid consolidation specs
Each workstream has two files: `OVERVIEW.md` (stable orientation) and `SPEC.md` (living implementation details).
- `shared/handoffs/`
- Cross-project security and transition docs
| Workstream | Overview | Implementation Spec | Status |
| -------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------- |
| Person 1 — WebView UI + Bridge | [OVERVIEW](./person1-webview/OVERVIEW.md) | [SPEC](./person1-webview/SPEC.md) | 28/29 done, 1 pending (dynamic proving config) |
| Person 2 — Native Shells (KMP + Swift) | [OVERVIEW](./person2-native-shells/OVERVIEW.md) | [SPEC](./person2-native-shells/SPEC.md) | 27/28 done, 1 pending (KMP test app validation) |
| Person 3 — Integrations | [OVERVIEW](./person3-integrations/OVERVIEW.md) | [MiniPay Spec](./person3-integrations/SPEC-MINIPAY-SAMPLE.md) | 26/26 done |
| Person 4 — SDK Core | [OVERVIEW](./person4-sdk-core/OVERVIEW.md) | [SPEC](./person4-sdk-core/SPEC.md) | 23/25 done, 2 pending (adapter dedup, crypto) |
| Person 5 — RN SDK | [OVERVIEW](./person5-rn-sdk/OVERVIEW.md) | [SPEC](./person5-rn-sdk/SPEC.md) | 21/23 done, 2 pending (wallet integration, npm) |
## Current Canonical Entry Points
- SDK: `specs/projects/sdk/INDEX.md` (planned)
- KMP: `specs/projects/kmp/KMP-SPECS-INDEX.md`
- Folder migration: `specs/SPECS-REORG-PLAN.md`
## Migration Map (Legacy -> Target)
- `specs/SDK-OVERVIEW.md` -> `specs/projects/sdk/SDK-OVERVIEW.md`
- `specs/WAVE-PLAN.md` -> `specs/projects/sdk/WAVE-PLAN.md`
- `specs/HANDOFF.md` -> `specs/projects/sdk/HANDOFF.md`
- `specs/person1-webview/*` -> `specs/projects/sdk/workstreams/webview/*`
- `specs/person2-native-shells/*` -> `specs/projects/sdk/workstreams/native-shells/*`
- `specs/person3-integrations/*` -> `specs/projects/sdk/workstreams/integrations/*`
- `specs/person4-sdk-core/*` -> `specs/projects/sdk/workstreams/sdk-core/*`
- `specs/person5-rn-sdk/*` -> `specs/projects/sdk/workstreams/rn-sdk/*`
- `specs/KMP-STATUS.md` -> `specs/projects/kmp/status/KMP-STATUS.md`
- `specs/lottie-dotlottie-migration/REVIEW.md` -> `specs/projects/lottie/REVIEW.md`
- `specs/EUCLID-WEB-CONSOLIDATION.md` -> `specs/projects/euclid/EUCLID-WEB-CONSOLIDATION.md`
- `specs/handoff-p1-fixes/*` -> `specs/shared/handoffs/p1-fixes/*`
## Reading Order
**New to the project?**
1. This README
2. [SDK-OVERVIEW.md](./SDK-OVERVIEW.md) — understand the architecture
3. Your workstream's `OVERVIEW.md` — understand what you own
4. Your workstream's `SPEC.md` — understand what to build
**Starting a work session?**
1. [WAVE-PLAN.md](./WAVE-PLAN.md) — find the next available chunk
2. Your workstream's `SPEC.md` — read the chunk, check status
3. [PROJECT-RULES.md](./PROJECT-RULES.md) — refresh on guardrails
**Writing a new spec?**
1. [SPEC-GUIDE.md](./SPEC-GUIDE.md) — how to write specs
2. [TEMPLATES.md](./TEMPLATES.md) — copy-paste the right template
3. [PROJECT-RULES.md](./PROJECT-RULES.md) — project-specific constraints
1. `specs/README.md`
2. Your project index under `specs/projects/<project>/`
3. Relevant framework docs in `specs/framework/`
4. Project workstream specs

162
specs/SPECS-REORG-PLAN.md Normal file
View File

@@ -0,0 +1,162 @@
# Specs Folder Reorganization Plan (Project-First)
Last updated: March 5, 2026
Owner: SDK/specs maintainers
Status: Proposed
## Decision
Use a **project-first** structure for `specs/`.
Why:
- Work typically starts from project context (`kmp`, `lottie`, `sdk`, `euclid`).
- Agents can resolve scope faster with one project root.
- Cross-project docs are the minority and can live in shared buckets.
## Goals
- Make each project's docs discoverable under one folder.
- Keep generic authoring rules separate from project specs.
- Reduce mixed flat files and stale links.
- Preserve compatibility during migration with path mapping.
## Target Structure
```text
specs/
README.md
SPECS-REORG-PLAN.md
framework/
SPEC-GUIDE.md
TEMPLATES.md
PROJECT-RULES.md
PRODUCT-SPEC-ENHANCEMENT-PROMPT.md
projects/
sdk/
INDEX.md
SDK-OVERVIEW.md
WAVE-PLAN.md
HANDOFF.md
workstreams/
webview/
native-shells/
integrations/
sdk-core/
rn-sdk/
kmp/
INDEX.md
KMP-ARCHITECTURE.md
KMP-INITIATIVE.md
KMP-REORG-PLAN.md
status/
KMP-STATUS.md
workstreams/
lottie/
INDEX.md
REVIEW.md
euclid/
INDEX.md
EUCLID-WEB-CONSOLIDATION.md
shared/
handoffs/
p1-fixes/
SECURITY-HARDENING.md
archive/
ARCHIVE.md # append-only table of retired specs
sdk/ # full-text copies of retired SDK specs (optional)
kmp/ # full-text copies of retired KMP specs (optional)
```
## Placement Rules
1. If a spec is mainly about one project, place it under `specs/projects/<project>/`.
2. Only generic spec system docs go in `specs/framework/`.
3. Cross-project coordination and follow-ups go in `specs/shared/`.
4. Every project folder should have an `INDEX.md` as its entrypoint.
5. New implementation specs should include: `Owner`, `Status`, `Last updated`, `Validation commands`.
6. When a spec is fully done: add a row to `specs/ARCHIVE.md` with outcome + key decisions. Either delete the source files (if the "What Was Built" appendix was added per SPEC-GUIDE) or move them to `specs/archive/<project>/`. Workstream OVERVIEW.md files stay until the workstream itself is retired.
## Migration Map (Current -> Target)
Framework:
- `specs/SPEC-GUIDE.md` -> `specs/framework/SPEC-GUIDE.md`
- `specs/TEMPLATES.md` -> `specs/framework/TEMPLATES.md`
- `specs/PROJECT-RULES.md` -> `specs/framework/PROJECT-RULES.md`
- `specs/PRODUCT-SPEC-ENHANCEMENT-PROMPT.md` -> `specs/framework/PRODUCT-SPEC-ENHANCEMENT-PROMPT.md`
SDK project:
- `specs/SDK-OVERVIEW.md` -> `specs/projects/sdk/SDK-OVERVIEW.md`
- `specs/WAVE-PLAN.md` -> `specs/projects/sdk/WAVE-PLAN.md`
- `specs/HANDOFF.md` -> `specs/projects/sdk/HANDOFF.md`
- `specs/person1-webview/*` -> `specs/projects/sdk/workstreams/webview/*`
- `specs/person2-native-shells/*` -> `specs/projects/sdk/workstreams/native-shells/*`
- `specs/person3-integrations/*` -> `specs/projects/sdk/workstreams/integrations/*`
- `specs/person4-sdk-core/*` -> `specs/projects/sdk/workstreams/sdk-core/*`
- `specs/person5-rn-sdk/*` -> `specs/projects/sdk/workstreams/rn-sdk/*`
KMP project:
- `specs/KMP-STATUS.md` -> `specs/projects/kmp/status/KMP-STATUS.md`
- `specs/projects/kmp/KMP-*.md` -> keep under `specs/projects/kmp/`
Lottie project:
- `specs/lottie-dotlottie-migration/REVIEW.md` -> `specs/projects/lottie/REVIEW.md`
Euclid project:
- `specs/EUCLID-WEB-CONSOLIDATION.md` -> `specs/projects/euclid/EUCLID-WEB-CONSOLIDATION.md`
Shared:
- `specs/handoff-p1-fixes/*` -> `specs/shared/handoffs/p1-fixes/*`
## Rollout Phases
### Phase 1: Index and Policy
- Update `specs/README.md` to project-first navigation.
- Keep a migration mapping table in `README` during transition.
### Phase 2: Create Target Dirs
- Create `framework/`, `projects/*`, `shared/` roots.
- Add `INDEX.md` placeholders for `sdk`, `kmp`, `lottie`, `euclid`.
### Phase 3: Move Project-Level Docs
- Move `SDK-OVERVIEW`, `WAVE-PLAN`, `HANDOFF`, KMP status, lottie review, euclid consolidation.
- Update links in moved docs.
### Phase 4: Move Workstreams
- Move `person*` directories to `projects/sdk/workstreams/*`.
- Update references from old paths.
### Phase 5: Cleanup
- Remove old-path mapping table after link convergence.
- Add optional metadata validation script for required headings.
## Validation Checklist
- `find specs -maxdepth 5 -type f | sort`
- `rg -n "person[1-5]-|KMP-STATUS.md|lottie-dotlottie-migration|EUCLID-WEB-CONSOLIDATION" specs`
- `rg -n "\]\(\./" specs` (relative-link sanity)
## Immediate Next Steps
1. Create project `INDEX.md` files for `sdk`, `kmp`, `lottie`, `euclid`.
2. Move `KMP-STATUS.md` into `projects/kmp/status/`.
3. Move `lottie-dotlottie-migration/REVIEW.md` into `projects/lottie/`.
4. Move `EUCLID-WEB-CONSOLIDATION.md` into `projects/euclid/`.

View File

@@ -32,11 +32,11 @@ The Self Wallet is a monolithic React Native app where all logic, NFC, proving,
2. Bridges to native only for hardware/OS capabilities (NFC, camera, biometrics, keychain, lifecycle)
3. Provides web-native fallback adapters for everything the browser can handle (documents via IndexedDB, crypto hashing via Web Crypto, analytics via console/fetch)
| Area | Issue |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/webview-bridge/` | Implemented with current protocol/adapters and validated by tests (63 tests passing). |
| `packages/webview-app/` | Screens built, routing works. Missing: biometrics + camera adapter wiring, web fallback adapters not all connected in SelfClientProvider. |
| Web fallback adapters | IndexedDB documents adapter, Web Crypto hashing adapter, and console analytics adapter exist in bridge package but need wiring in webview-app. |
| Area | Issue |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `packages/webview-bridge/` | Implemented with current protocol/adapters and validated by tests (63 tests passing). |
| `packages/webview-app/` | Screens built, routing works. SelfClientProvider wiring complete (biometrics, camera, and web fallback adapters connected). |
| Web fallback adapters | IndexedDB documents, Web Crypto hashing, console analytics, navigation, and no-op haptic are wired in webview-app. |
## Design Principles
@@ -1331,13 +1331,13 @@ Chunk 1F: Bridge Package (no deps — start here)
## Completion Status
| Chunk | Description | Size | Status |
| ----- | ------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------- |
| 1F | Bridge Package | L | **Done** — 63 tests pass, bridge package and adapters implemented |
| 1B | Onboarding Screens | M | **Done** — all 5 screens render |
| 1C | Proving + Result Screens | M | **Done** — screens render, proving wired |
| 1D | Remaining Screens | S | **Done** — home, settings, coming-soon render |
| 1E | WebView App Shell | M | **In Progress** — providers wired, missing: biometrics adapter, camera wiring, some web fallback adapter connections |
| Chunk | Description | Size | Status |
| ----- | ------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------ |
| 1F | Bridge Package | L | **Done** — 63 tests pass, bridge package and adapters implemented |
| 1B | Onboarding Screens | M | **Done** — all 5 screens render |
| 1C | Proving + Result Screens | M | **Done** — screens render, proving wired |
| 1D | Remaining Screens | S | **Done** — home, settings, coming-soon render |
| 1E | WebView App Shell | M | **Done** — providers/router/entry wired, biometrics + camera adapters connected, web fallbacks connected, lifecycle.ready() on mount |
## Validation Plan

View File

@@ -22,7 +22,7 @@
- [x] iOS Swift providers are implemented and wired (NFC, Biometrics, Lifecycle, WebView host + additional providers)
- [x] `SelfSdk.launch()` flow is implemented on iOS
- [x] Shared KMP validation baseline captured (`:shared:compileKotlinIosSimulatorArm64` + `:shared:jvmTest` successful)
- [ ] KMP test app validation on both platforms remains a follow-up validation task
- [x] KMP test app validation on both platforms completed (Android assemble + iOS compile)
- [x] Platform asymmetry contract documented and signed off (iOS 9-handler superset vs Android 5-handler core set)
- [x] MiniPay sample integration is wired (`SelfSdk.launch()` call path present)

View File

@@ -1694,7 +1694,7 @@ SelfSdkCallback fires on completion/dismissal
| `SelfSdkCallback.onSuccess` fires | Integration | Result delivery from WebView through lifecycle handler |
| `SelfSdkCallback.onCancelled` fires on dismiss | Integration | Dismiss wiring works correctly |
**Status: PARTIAL** (Android + iOS implementation paths are present; full cross-platform validation remains)
**Status: DONE** (Android common launch signature fixed, test app now exercises `SelfSdk.configure(...).launch(...)`, and validation gates pass)
---
@@ -2058,20 +2058,20 @@ Chunk 2A: KMP Setup + Bridge Protocol (no deps -- start here)
## Completion Status
| Chunk | Description | Size | Status |
| ----- | ------------------------------------------ | ------ | --------------------------------------------------------------------------- |
| 2A | KMP Setup + Bridge Protocol | S ~3k | **Done** |
| 2B | Android WebView Host | S ~2k | **Done** |
| 2C | Android Native Handlers (5 handlers) | L ~12k | **Done** |
| 2D | iOS WebView Host + Provider Infrastructure | M ~6k | **Superseded** by 2G-2K (Swift wrapper pattern) |
| 2E | iOS Native Handlers (3 handlers) | M ~6k | **Superseded** by 2G-2K (Swift wrapper pattern) |
| 2F | SDK Public API + Test App | M ~5k | **Partial** (implementation present; validation/contract alignment pending) |
| 2G | Factory Infrastructure | S ~3k | **Done** |
| 2H | Biometric Handler (iOS) | S ~2k | **Done** |
| 2I | Lifecycle Handler (iOS) | S ~2k | **Done** |
| 2J | iOS WebView Host + SelfSdk.launch() | M ~5k | **Done** |
| 2K | NFC Handler (iOS) | M ~5k | **Done** |
| 2L | Camera MRZ (iOS, Phase 2) | S ~2k | **Skipped** (deferred) |
| Chunk | Description | Size | Status |
| ----- | ------------------------------------------ | ------ | ----------------------------------------------------------------------------------------- |
| 2A | KMP Setup + Bridge Protocol | S ~3k | **Done** |
| 2B | Android WebView Host | S ~2k | **Done** |
| 2C | Android Native Handlers (5 handlers) | L ~12k | **Done** |
| 2D | iOS WebView Host + Provider Infrastructure | M ~6k | **Superseded** by 2G-2K (Swift wrapper pattern) |
| 2E | iOS Native Handlers (3 handlers) | M ~6k | **Superseded** by 2G-2K (Swift wrapper pattern) |
| 2F | SDK Public API + Test App | M ~5k | **Done** (common Android launch fixed, test app launch screen wired, validation complete) |
| 2G | Factory Infrastructure | S ~3k | **Done** |
| 2H | Biometric Handler (iOS) | S ~2k | **Done** |
| 2I | Lifecycle Handler (iOS) | S ~2k | **Done** |
| 2J | iOS WebView Host + SelfSdk.launch() | M ~5k | **Done** |
| 2K | NFC Handler (iOS) | M ~5k | **Done** |
| 2L | Camera MRZ (iOS, Phase 2) | S ~2k | **Skipped** (deferred) |
## Validation Plan
@@ -2161,11 +2161,12 @@ cd packages/self-sdk-swift && swift build
## Follow-Up (Out of Scope)
| Item | Discovered during | Suggested spec |
| ------------------------------ | ----------------- | --------------------------------------------------------------------------------------------- |
| Camera MRZ handler for iOS | Chunk 2L scoping | Phase 2 -- add to this spec when needed |
| SecureStorage handler for iOS | Design review | **Decided:** Add `SecureStorageProvider` to factory pattern (see SDK-OVERVIEW canonical rule) |
| Crypto signing handler for iOS | Design review | Depends on whether secure enclave signing is needed vs. Web Crypto |
| Item | Discovered during | Suggested spec |
| -------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Camera MRZ handler for iOS | Chunk 2L scoping | Phase 2 -- add to this spec when needed |
| SecureStorage handler for iOS | Design review | **Decided:** Add `SecureStorageProvider` to factory pattern (see SDK-OVERVIEW canonical rule) |
| Crypto signing handler for iOS | Design review | Depends on whether secure enclave signing is needed vs. Web Crypto |
| LifecycleBridgeHandler thin-wrapper refactor | PR #1805 review | Both Android and iOS `setResult()` have 4-branch business logic (interpreting `type`/`success`/`errorCode` to decide result codes / callback methods). Per PROJECT-RULES.md rule 22 ("no error mapping in native"), TypeScript should send an explicit `resultCode` or `outcome` field, and the handler should pass it through without interpretation. Touches both platform handlers + bridge protocol. |
## Spec Deviations

View File

@@ -0,0 +1,104 @@
# KMP Architecture
Last updated: March 5, 2026
Owner: KMP program
Status: Draft
## Purpose
Define the canonical architecture for KMP SDK delivery, runtime behavior, and integration contracts.
## Scope
In scope:
- KMP SDK (`packages/kmp-sdk`)
- KMP SDK test app (`packages/kmp-sdk-test-app` target name)
- Swift companion package (`packages/self-sdk-swift`)
- Bridge contract and handler lifecycle
Out of scope:
- Product requirements and roadmap sequencing (see `KMP-INITIATIVE.md`)
- Detailed task breakdowns per implementation chunk
## System Context
- Host apps launch SDK runtime.
- SDK runtime hosts WebView flow and routes bridge messages.
- Native handlers execute platform-specific actions.
- Results are returned through bridge responses to web layer.
## Module Boundaries
1. `kmp-sdk`
- Public API surface (`configure`, `launch`, callbacks)
- Shared bridge models and router
- Android and iOS native handler bindings
2. `self-sdk-swift`
- iOS-native provider implementations
- Factory/config wiring for KMP iOS side
3. `kmp-sdk-test-app`
- Integration harness for Android/iOS manual validation
- Non-production sample host and verification scenarios
## Runtime Flow
1. App initializes SDK config.
2. SDK launches WebView host.
3. Web layer sends bridge request.
4. Router dispatches to native handler.
5. Native result/error marshalled to bridge response.
6. Web flow proceeds or fails with typed error.
## Bridge Contract
Required sections for future expansion:
- Message envelope format
- Request/response typing rules
- Error code taxonomy
- Timeout/retry semantics
- Version compatibility policy
## Platform Notes
### Android
- Activity/webview host lifecycle ownership
- NFC/camera/permission handling responsibilities
- Threading model expectations
### iOS
- Provider delegation into Swift package
- View controller presentation ownership
- Permission and lifecycle edge cases
## Security and Privacy
- Sensitive data handling boundaries
- Logging restrictions (no secrets/PII)
- Storage and retention rules
## Validation Matrix
- Unit: router + handler contracts
- Build: android + ios compile targets
- Integration: test app verification flows
- Device: physical NFC/passport success and failure cases
## Open Decisions
- [ ] Decision 1: _TBD_
- [ ] Decision 2: _TBD_
- [ ] Decision 3: _TBD_
## Change Log
- 2026-03-05: Initial architecture skeleton created.

View File

@@ -0,0 +1,96 @@
# KMP Initiative
Last updated: March 5, 2026
Owner: KMP program
Status: Draft
## Problem Statement
KMP specs and DX entrypoints are currently fragmented across mixed naming and locations, increasing onboarding time and execution errors for contributors and agents.
## Goals
- Standardize KMP naming across package folders, commands, and specs.
- Consolidate KMP specs under one project-intent hierarchy.
- Establish architecture and initiative docs as canonical entrypoints.
- Improve agent execution reliability with explicit ownership and validation.
## Non-Goals
- Redesigning non-KMP project spec systems in this initiative.
- Reworking product requirements outside KMP scope.
- Renaming KMP workspace package names.
## Deliverables
1. KMP spec tree under `specs/projects/kmp/`
2. Command taxonomy (`kmp:sdk:*`, `kmp:test-app:*`, `kmp:all:*`)
3. Test app folder rename to `kmp-sdk-test-app` (workspace package name unchanged)
4. Migration map from legacy paths and command aliases
5. Agent hygiene fields enforced in KMP workstream specs
## Milestones
1. Foundation docs
- `KMP-SPECS-INDEX.md`
- `KMP-ARCHITECTURE.md`
- `KMP-INITIATIVE.md`
2. Command migration
- Add new namespaced commands
- Keep compatibility aliases
3. Naming migration
- Folder rename for test app (`packages/kmp-test-app` -> `packages/kmp-sdk-test-app`)
- Keep `@selfxyz/kmp-test-app` unchanged
- Repo-wide path reference update
4. Spec migration
- Move KMP-relevant docs into new hierarchy
- Add redirects/mapping
5. Cleanup
- Remove deprecated aliases after signoff window
## Owners
- Initiative lead: _TBD_
- DX/commands: _TBD_
- Specs migration: _TBD_
- Validation/CI: _TBD_
## Dependencies
- Agreement on final package folder naming
- Agreement on command namespace policy
- Coordination with ongoing KMP implementation work
## Risks
1. Stale links and references after migration
2. Temporary confusion during alias period
3. Spec drift without ownership/date stamping
## Acceptance Criteria
- New KMP entrypoint exists and is linked from top-level specs index.
- New commands cover all existing KMP workflows.
- Legacy references are either migrated or mapped.
- KMP specs include owner/dependencies/validation metadata.
## Rollout Plan
1. Land docs and command taxonomy.
2. Land package folder rename.
3. Land spec migration and mapping.
4. Remove deprecated aliases after agreed window.
## Change Log
- 2026-03-05: Initial initiative skeleton created.
- 2026-03-05: Updated naming plan to folder rename only; workspace package name unchanged.

View File

@@ -0,0 +1,190 @@
# KMP Specs and DX Reorganization Plan
Last updated: March 5, 2026
Owner: KMP program
Status: Proposed
## Objective
Reorganize KMP specs and developer experience surface areas so contributors and agents can find the right docs quickly, run the right commands consistently, and execute changes with clear ownership boundaries.
## Success Criteria
- KMP specs are grouped by project intent under a dedicated KMP project tree.
- KMP package naming is consistent (`kmp-sdk`, `kmp-sdk-test-app` folder naming).
- Root command surface is explicit, discoverable, and backward-compatible during migration.
- Architecture and initiative docs exist and become the canonical KMP entrypoints.
- Agents can navigate and execute work with minimal ambiguity.
## Naming and Structure Standards
1. Package naming standard
- Keep SDK package as `kmp-sdk`.
- Rename test host app folder from `kmp-test-app` to `kmp-sdk-test-app`.
- Keep workspace package name as `@selfxyz/kmp-test-app` (no package rename).
2. Spec naming standard
- Use `KMP-` prefix for KMP-wide docs.
- Use `KMP-<TRACK>-<TOPIC>.md` for scoped docs (example: `KMP-NATIVE-API.md`).
3. Canonical KMP spec tree
- `specs/projects/kmp/README.md`
- `specs/projects/kmp/KMP-ARCHITECTURE.md`
- `specs/projects/kmp/KMP-INITIATIVE.md`
- `specs/projects/kmp/status/KMP-STATUS.md`
- `specs/projects/kmp/workstreams/*`
- `specs/projects/kmp/KMP-SPECS-INDEX.md`
- `specs/projects/kmp/KMP-DECISIONS.md`
- `specs/projects/kmp/KMP-CHANGELOG.md`
## Command Surface Reorganization
Update root `package.json` KMP scripts into explicit namespaces:
1. SDK commands
- `kmp:sdk:build`
- `kmp:sdk:test`
- `kmp:sdk:lint`
- `kmp:sdk:format`
- `kmp:sdk:clean`
2. Test app commands
- `kmp:test-app:android`
- `kmp:test-app:ios`
- `kmp:test-app:build`
- `kmp:test-app:test`
- `kmp:test-app:lint`
- `kmp:test-app:format`
- `kmp:test-app:clean`
3. Orchestration commands
- `kmp:all:check` (lint + test + build)
- `kmp:all:clean`
- `kmp:all:dev`
4. Backward compatibility
- Keep existing `kmp:*` aliases mapped to new commands for 1-2 release cycles.
- Add deprecation notes in script descriptions and docs.
## Required New Docs
1. `KMP-ARCHITECTURE.md`
- Module boundaries and ownership
- Runtime flow diagrams (Android/iOS)
- Bridge contract and handler lifecycle
- Integration points with RN SDK/WebView artifacts
- Risk areas and validation matrix
2. `KMP-INITIATIVE.md`
- Problem statement
- Goals and non-goals
- Milestones and deliverables
- Owners and decision records
- Rollout and acceptance criteria
## Agent-Focused Spec Hygiene
Add the following to each KMP workstream spec:
- `Owner`
- `Depends On`
- `Inputs`
- `Outputs`
- `Safe-to-edit paths`
- `Do-not-edit paths`
- `Validation commands`
- `Last verified` date
Add a lightweight validator script to enforce required headings in KMP spec files.
## Migration Plan
### Phase 1: Foundations
- Create KMP spec project tree under `specs/projects/kmp/`.
- Add `KMP-ARCHITECTURE.md` and `KMP-INITIATIVE.md` skeletons.
- Publish `KMP-SPECS-INDEX.md` as the KMP entrypoint.
Exit criteria:
- KMP entry docs exist and are linked from `specs/README.md`.
### Phase 2: Command Taxonomy
- Add new namespaced KMP commands to root `package.json`.
- Add compatibility aliases from old `kmp:*` commands.
- Document command matrix (`task -> command -> expected output`).
Exit criteria:
- All existing KMP workflows work through new commands.
### Phase 3: Package Folder Rename
- Rename folder `packages/kmp-test-app` to `packages/kmp-sdk-test-app`.
- Keep workspace package name as `@selfxyz/kmp-test-app`.
- Update all path references in scripts, Gradle settings, docs, and specs.
Exit criteria:
- No path references remain to `packages/kmp-test-app`.
- Workspace package name remains unchanged.
### Phase 4: Spec Migration
- Move KMP-relevant specs into `specs/projects/kmp/` buckets.
- Add compatibility index in root `specs/README.md` mapping old paths to new paths.
- Add `KMP-CHANGELOG.md` and `KMP-DECISIONS.md`.
Exit criteria:
- KMP spec navigation starts at one path and old references are redirected.
### Phase 5: Deprecation Cleanup
- Remove old command aliases after signoff window.
- Remove transitional links once references converge.
- Run full lint/types/build/tests for impacted workspaces.
Exit criteria:
- No transitional aliases or duplicate KMP spec locations remain.
## Risks and Mitigations
1. Broken references after rename
- Mitigation: perform repo-wide search/replace + validation pass before merge.
2. Temporary confusion during command transition
- Mitigation: keep aliases and publish command matrix with examples.
3. Spec drift after migration
- Mitigation: add `Last verified` and heading-validator checks in CI.
## Validation Checklist
- `yarn lint`
- `yarn types`
- `yarn build`
- `yarn test`
- `rg -n "packages/kmp-test-app" .`
- `rg -n "specs/projects/kmp" specs/README.md`
## Immediate Next Actions
1. Create architecture and initiative doc skeletons in `specs/projects/kmp/`.
2. Add new root KMP command taxonomy with alias compatibility.
3. Prepare and execute package folder rename in one focused PR.
4. Migrate KMP specs and add redirect mapping.

View File

@@ -0,0 +1,41 @@
# KMP Specs Index
Last updated: March 5, 2026
Owner: KMP program
Status: Draft
## Start Here
1. `KMP-INITIATIVE.md` — goals, scope, milestones.
2. `KMP-ARCHITECTURE.md` — technical boundaries and runtime model.
3. `KMP-REORG-PLAN.md` — migration phases and execution checklist.
## Canonical Paths
- `specs/projects/kmp/KMP-INITIATIVE.md`
- `specs/projects/kmp/KMP-ARCHITECTURE.md`
- `specs/projects/kmp/KMP-REORG-PLAN.md`
- `specs/projects/kmp/status/KMP-STATUS.md` (planned)
- `specs/projects/kmp/workstreams/` (planned)
- `specs/projects/kmp/KMP-DECISIONS.md` (planned)
- `specs/projects/kmp/KMP-CHANGELOG.md` (planned)
## Working Rules for Agents
- Prefer canonical KMP paths over legacy `person*` docs for KMP planning.
- Record `Last updated` date on each material change.
- Include validation commands in implementation-facing specs.
- Mark unresolved items in `Open Decisions` sections.
## Migration Tracking
- [x] Reorg plan created
- [x] Architecture skeleton created
- [x] Initiative skeleton created
- [ ] Status moved to `specs/projects/kmp/status/KMP-STATUS.md`
- [ ] Workstreams reorganized under `specs/projects/kmp/workstreams/`
- [ ] Legacy path mapping added to top-level `specs/README.md`
## Change Log
- 2026-03-05: Initial KMP spec index skeleton created.