From 6ce2507cc47c5bcb88ec52623eb8c4e094459b61 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:22:12 +0530 Subject: [PATCH] SELF-2323: fix: keychain to webview communication (#1871) * fix: keychain to webview communication * lint * update coderabbit comments * lint --- .../xyz/self/sdk/bridge/MessageRouter.kt | 5 +- .../self/sdk/handlers/SecureStorageHandler.kt | 5 +- .../self/sdk/webview/AndroidWebViewHost.kt | 5 +- packages/webview-app/src/App.tsx | 2 + .../src/screens/account/DevModeScreen.tsx | 77 ++-- .../src/screens/debug/KeychainDebugScreen.tsx | 345 ++++++++++++++++++ 6 files changed, 409 insertions(+), 30 deletions(-) create mode 100644 packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/bridge/MessageRouter.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/bridge/MessageRouter.kt index fb033ec0c..cfeb40635 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/bridge/MessageRouter.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/bridge/MessageRouter.kt @@ -16,7 +16,7 @@ class MessageRouter( private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), ) { private val handlers = mutableMapOf() - private val json = Json { ignoreUnknownKeys = true } + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } fun register(handler: BridgeHandler) { handlers[handler.domain] = handler @@ -26,8 +26,10 @@ class MessageRouter( val request = try { json.decodeFromString(rawJson) } catch (e: Exception) { + android.util.Log.e("BridgeRouter", "Failed to decode request: ${e::class.simpleName}") return } + android.util.Log.d("BridgeRouter", "Received: domain=${request.domain} method=${request.method}") if (request.version != BRIDGE_PROTOCOL_VERSION) { sendResponse( @@ -118,6 +120,7 @@ class MessageRouter( private fun sendResponse(response: BridgeResponse) { val responseJson = json.encodeToString(response) + android.util.Log.d("BridgeRouter", "Sending response: domain=${response.domain} success=${response.success}") sendToWebView("window.SelfNativeBridge._handleResponse(${escapeForJs(responseJson)})") } diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt index c191d2ff3..59fcdd3ee 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt @@ -9,6 +9,7 @@ import androidx.security.crypto.MasterKey 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 @@ -48,7 +49,9 @@ class SecureStorageHandler(context: Context) : BridgeHandler { 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 + return buildJsonObject { + put("value", if (value != null) JsonPrimitive(value) else JsonNull) + } } private fun set(params: Map): JsonElement? { diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt index dbe8553c7..a83846ca0 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt @@ -103,7 +103,10 @@ class AndroidWebViewHost( } fun evaluateJs(js: String) { - if (!::webView.isInitialized) return + if (!::webView.isInitialized) { + android.util.Log.e("WebViewHost", "evaluateJs called but webView not initialized") + return + } webView.evaluateJavascript(js, null) } diff --git a/packages/webview-app/src/App.tsx b/packages/webview-app/src/App.tsx index cad3fd4cd..57947fb79 100644 --- a/packages/webview-app/src/App.tsx +++ b/packages/webview-app/src/App.tsx @@ -12,6 +12,7 @@ import { NotificationPreferencesScreen } from './screens/account/NotificationPre import { SecurityScreen } from './screens/account/SecurityScreen'; import { SettingsScreen } from './screens/account/SettingsScreen'; import { ComingSoonScreen } from './screens/ComingSoonScreen'; +import { KeychainDebugScreen } from './screens/debug/KeychainDebugScreen'; import { HomeScreen } from './screens/home/HomeScreen'; import { ConfirmIdentificationScreen } from './screens/onboarding/ConfirmIdentificationScreen'; import { CountryPickerScreen } from './screens/onboarding/CountryPickerScreen'; @@ -45,6 +46,7 @@ export const App: React.FC = () => ( } /> } /> } /> + {import.meta.env.DEV && } />} } /> } /> } /> diff --git a/packages/webview-app/src/screens/account/DevModeScreen.tsx b/packages/webview-app/src/screens/account/DevModeScreen.tsx index 58edf0a13..c456f26ba 100644 --- a/packages/webview-app/src/screens/account/DevModeScreen.tsx +++ b/packages/webview-app/src/screens/account/DevModeScreen.tsx @@ -58,32 +58,55 @@ export const DevModeScreen: React.FC = () => { }, [navigate, haptic, analytics, documentType, nationality, ageIndex, expiryIndex, ofacCheck]); return ( - } - onBack={onBack} - idCard={idCard} - documentType={documentType} - onDocumentTypePress={() => { - setDocumentType(prev => (prev === 'passport' ? 'id_card' : 'passport')); - }} - nationality={nationality} - onNationalityPress={() => { - setNationality(prev => (prev === 'united states of america' ? 'germany' : 'united states of america')); - }} - age={ageOptions[ageIndex]} - onAgeIncrement={() => setAgeIndex(prev => Math.min(prev + 1, ageOptions.length - 1))} - onAgeDecrement={() => setAgeIndex(prev => Math.max(prev - 1, 0))} - documentExpiresIn={expiryOptions[expiryIndex]} - onDocumentExpiresIncrement={() => setExpiryIndex(prev => Math.min(prev + 1, expiryOptions.length - 1))} - onDocumentExpiresDecrement={() => setExpiryIndex(prev => Math.max(prev - 1, 0))} - ofacCheck={ofacCheck} - onOfacCheckChange={value => { - haptic.trigger('selection'); - setOfacCheck(value); - }} - onResetAllValues={onResetAllValues} - onGenerateMockDocument={onGenerateMockDocument} - /> + <> + } + onBack={onBack} + idCard={idCard} + documentType={documentType} + onDocumentTypePress={() => { + setDocumentType(prev => (prev === 'passport' ? 'id_card' : 'passport')); + }} + nationality={nationality} + onNationalityPress={() => { + setNationality(prev => (prev === 'united states of america' ? 'germany' : 'united states of america')); + }} + age={ageOptions[ageIndex]} + onAgeIncrement={() => setAgeIndex(prev => Math.min(prev + 1, ageOptions.length - 1))} + onAgeDecrement={() => setAgeIndex(prev => Math.max(prev - 1, 0))} + documentExpiresIn={expiryOptions[expiryIndex]} + onDocumentExpiresIncrement={() => setExpiryIndex(prev => Math.min(prev + 1, expiryOptions.length - 1))} + onDocumentExpiresDecrement={() => setExpiryIndex(prev => Math.max(prev - 1, 0))} + ofacCheck={ofacCheck} + onOfacCheckChange={value => { + haptic.trigger('selection'); + setOfacCheck(value); + }} + onResetAllValues={onResetAllValues} + onGenerateMockDocument={onGenerateMockDocument} + /> + {import.meta.env.DEV && ( + + )} + ); }; diff --git a/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx b/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx new file mode 100644 index 000000000..3d84994b4 --- /dev/null +++ b/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx @@ -0,0 +1,345 @@ +// 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. + +import type React from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { bridgeStorageAdapter } from '@selfxyz/webview-bridge/adapters'; + +import { useBridge } from '../../providers/BridgeProvider'; +import { useSelfClient } from '../../providers/SelfClientProvider'; + +interface LogEntry { + time: string; + message: string; + error?: boolean; +} + +function timestamp(): string { + return new Date().toLocaleTimeString('en-US', { hour12: false }); +} + +export const KeychainDebugScreen: React.FC = () => { + const navigate = useNavigate(); + const bridge = useBridge(); + const { documents } = useSelfClient(); + const storage = useRef(bridgeStorageAdapter(bridge)).current; + + const [key, setKey] = useState('test-key'); + const [value, setValue] = useState('hello-world'); + const [docId, setDocId] = useState('mock-doc-1'); + const [log, setLog] = useState([]); + + const addLog = useCallback((message: string, error = false) => { + setLog(prev => [...prev, { time: timestamp(), message, error }]); + }, []); + + const handlePing = useCallback(async () => { + addLog(`Bridge: connected=${bridge.isConnected}, pending=${bridge.pendingCount}`); + try { + const result = await bridge.request('secureStorage', 'get', { key: '__ping__' }, 5000); + addLog(`PING OK -> ${JSON.stringify(result)}`); + } catch (e) { + addLog(`PING FAILED: ${e}`, true); + } + }, [bridge, addLog]); + + const handleSet = useCallback(async () => { + try { + await storage.set(key, value); + addLog(`SET "${key}" = "${value}" -> OK`); + } catch (e) { + addLog(`SET "${key}" FAILED: ${e}`, true); + } + }, [storage, key, value, addLog]); + + const handleGet = useCallback(async () => { + try { + const result = await storage.get(key); + addLog(`GET "${key}" -> ${result === null ? 'null' : `"${result}"`}`); + } catch (e) { + addLog(`GET "${key}" FAILED: ${e}`, true); + } + }, [storage, key, addLog]); + + const handleRemove = useCallback(async () => { + try { + await storage.remove(key); + addLog(`REMOVE "${key}" -> OK`); + } catch (e) { + addLog(`REMOVE "${key}" FAILED: ${e}`, true); + } + }, [storage, key, addLog]); + + const handleSaveDoc = useCallback(async () => { + try { + const mockDoc = { + documentType: 'passport', + issuingCountry: 'US', + nationality: 'USA', + dateOfBirth: '1990-01-15', + dateOfExpiry: '2030-06-20', + documentNumber: 'X12345678', + firstName: 'Debug', + lastName: 'User', + }; + await documents.saveDocument(docId, mockDoc as never); + + const catalog = await documents.loadDocumentCatalog(); + const entry = { + id: docId, + documentType: 'passport', + documentCategory: 'passport' as const, + data: '', + mock: true, + }; + const existing = catalog.documents.findIndex((d: { id: string }) => d.id === docId); + if (existing >= 0) { + catalog.documents[existing] = entry; + } else { + catalog.documents.push(entry); + } + await documents.saveDocumentCatalog(catalog); + + addLog(`SAVE DOC "${docId}" + catalog -> OK`); + } catch (e) { + addLog(`SAVE DOC "${docId}" FAILED: ${e}`, true); + } + }, [documents, docId, addLog]); + + const handleLoadDoc = useCallback(async () => { + try { + const doc = await documents.loadDocumentById(docId); + addLog(`LOAD DOC "${docId}" -> ${doc === null ? 'null' : JSON.stringify(doc).slice(0, 120)}`); + } catch (e) { + addLog(`LOAD DOC "${docId}" FAILED: ${e}`, true); + } + }, [documents, docId, addLog]); + + const handleLoadCatalog = useCallback(async () => { + try { + const catalog = await documents.loadDocumentCatalog(); + addLog(`CATALOG -> ${JSON.stringify(catalog)}`); + } catch (e) { + addLog(`CATALOG FAILED: ${e}`, true); + } + }, [documents, addLog]); + + const handleDeleteDoc = useCallback(async () => { + try { + await documents.deleteDocument(docId); + + const catalog = await documents.loadDocumentCatalog(); + catalog.documents = catalog.documents.filter((d: { id: string }) => d.id !== docId); + await documents.saveDocumentCatalog(catalog); + + addLog(`DELETE DOC "${docId}" + catalog update -> OK`); + } catch (e) { + addLog(`DELETE DOC "${docId}" FAILED: ${e}`, true); + } + }, [documents, docId, addLog]); + + return ( +
+
+ +

Keychain Debug

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

Raw Secure Storage

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

Documents Adapter

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

Log

+ +
+
+ {log.length === 0 && No operations yet} + {log.map((entry, i) => ( +
+ {entry.time} {entry.message} +
+ ))} +
+
+
+ ); +}; + +const styles: Record = { + container: { + padding: 16, + fontFamily: 'system-ui, sans-serif', + maxWidth: 480, + margin: '0 auto', + color: '#e0e0e0', + backgroundColor: '#1a1a2e', + minHeight: '100vh', + }, + header: { + display: 'flex', + alignItems: 'center', + gap: 12, + marginBottom: 20, + }, + backButton: { + background: 'none', + border: 'none', + color: '#7c8aff', + fontSize: 18, + cursor: 'pointer', + padding: '4px 8px', + }, + title: { + margin: 0, + fontSize: 20, + fontWeight: 600, + }, + section: { + marginBottom: 20, + padding: 12, + borderRadius: 8, + backgroundColor: '#16213e', + }, + sectionTitle: { + margin: '0 0 10px', + fontSize: 14, + fontWeight: 600, + textTransform: 'uppercase' as const, + letterSpacing: 1, + color: '#7c8aff', + }, + row: { + display: 'flex', + gap: 8, + marginBottom: 8, + }, + input: { + flex: 1, + padding: '8px 10px', + borderRadius: 6, + border: '1px solid #333', + backgroundColor: '#0f3460', + color: '#e0e0e0', + fontSize: 14, + outline: 'none', + }, + button: { + padding: '8px 14px', + borderRadius: 6, + border: 'none', + backgroundColor: '#7c8aff', + color: '#fff', + fontSize: 13, + fontWeight: 600, + cursor: 'pointer', + }, + dangerButton: { + backgroundColor: '#e94560', + }, + logSection: { + flex: 1, + }, + logHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + clearButton: { + background: 'none', + border: '1px solid #555', + color: '#aaa', + borderRadius: 4, + padding: '4px 10px', + fontSize: 12, + cursor: 'pointer', + }, + logArea: { + backgroundColor: '#0a0a1a', + borderRadius: 8, + padding: 10, + maxHeight: 300, + overflowY: 'auto' as const, + fontSize: 12, + fontFamily: 'monospace', + lineHeight: 1.6, + }, + placeholder: { + color: '#555', + fontStyle: 'italic', + }, + logEntry: { + color: '#a0e0a0', + wordBreak: 'break-all' as const, + }, + logError: { + color: '#e94560', + wordBreak: 'break-all' as const, + }, + logTime: { + color: '#666', + }, +};