SELF-2323: fix: keychain to webview communication (#1871)

* fix: keychain to webview communication

* lint

* update coderabbit comments

* lint
This commit is contained in:
Seshanth.S
2026-03-26 14:22:12 +05:30
committed by GitHub
parent ff66899eca
commit 6ce2507cc4
6 changed files with 409 additions and 30 deletions

View File

@@ -16,7 +16,7 @@ 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
@@ -26,8 +26,10 @@ class MessageRouter(
val request = try {
json.decodeFromString<BridgeRequest>(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)})")
}

View File

@@ -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<String, JsonElement>): JsonElement? {

View File

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

View File

@@ -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 = () => (
<Route path="/settings/security" element={<SecurityScreen />} />
<Route path="/settings/notifications" element={<NotificationPreferencesScreen />} />
<Route path="/settings/dev-mode" element={<DevModeScreen />} />
{import.meta.env.DEV && <Route path="/debug/keychain" element={<KeychainDebugScreen />} />}
<Route path="/account/verified" element={<VerificationResultScreen />} />
<Route path="/coming-soon" element={<ComingSoonScreen />} />
<Route path="/tunnel/tour/:step" element={<TourScreen />} />

View File

@@ -58,32 +58,55 @@ export const DevModeScreen: React.FC = () => {
}, [navigate, haptic, analytics, documentType, nationality, ageIndex, expiryIndex, ofacCheck]);
return (
<EuclidDevModeScreen
insets={{ top: 0, bottom: 0 }}
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
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}
/>
<>
<EuclidDevModeScreen
insets={{ top: 0, bottom: 0 }}
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
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 && (
<button
onClick={() => navigate('/debug/keychain')}
style={{
position: 'fixed',
bottom: 24,
right: 24,
padding: '10px 18px',
borderRadius: 8,
border: 'none',
backgroundColor: '#7c8aff',
color: '#fff',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
zIndex: 100,
}}
>
Keychain Debug
</button>
)}
</>
);
};

View File

@@ -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<LogEntry[]>([]);
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 (
<div style={styles.container}>
<div style={styles.header}>
<button style={styles.backButton} onClick={() => navigate('/settings/dev-mode')}>
&larr; Back
</button>
<h2 style={styles.title}>Keychain Debug</h2>
<span style={{ marginLeft: 'auto', fontSize: 12, color: bridge.isConnected ? '#a0e0a0' : '#e94560' }}>
{bridge.isConnected ? 'Bridge connected' : 'No transport'}
</span>
</div>
<div style={styles.section}>
<div style={styles.row}>
<button style={{ ...styles.button, backgroundColor: '#e0a030' }} onClick={handlePing}>
Ping Bridge (5s timeout)
</button>
</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>
<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>
<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>
</div>
</div>
<div style={styles.logSection}>
<div style={styles.logHeader}>
<h3 style={styles.sectionTitle}>Log</h3>
<button style={styles.clearButton} onClick={() => setLog([])}>
Clear
</button>
</div>
<div style={styles.logArea}>
{log.length === 0 && <span style={styles.placeholder}>No operations yet</span>}
{log.map((entry, i) => (
<div key={i} style={entry.error ? styles.logError : styles.logEntry}>
<span style={styles.logTime}>{entry.time}</span> {entry.message}
</div>
))}
</div>
</div>
</div>
);
};
const styles: Record<string, React.CSSProperties> = {
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',
},
};