diff --git a/common/src/utils/types.ts b/common/src/utils/types.ts index 23bd717c1..1a2c29690 100644 --- a/common/src/utils/types.ts +++ b/common/src/utils/types.ts @@ -43,6 +43,7 @@ export interface DocumentMetadata { data: string; // DG1/MRZ data for passports/IDs, relevant data for aadhaar mock: boolean; // whether this is a mock document isRegistered?: boolean; // whether the document is registered onChain + registeredAt?: number; // timestamp (epoch ms) when document was registered } export type DocumentType = diff --git a/packages/mobile-sdk-alpha/src/documents/utils.ts b/packages/mobile-sdk-alpha/src/documents/utils.ts index 6c7e56c36..7603eee1f 100644 --- a/packages/mobile-sdk-alpha/src/documents/utils.ts +++ b/packages/mobile-sdk-alpha/src/documents/utils.ts @@ -13,10 +13,12 @@ import { brutforceSignatureAlgorithmDsc, calculateContentHash, inferDocumentCategory, + isAadhaarDocument, isMRZDocument, parseCertificateSimple, } from '@selfxyz/common'; +import { extractNameFromMRZ } from '../processing/mrz'; import { SelfClient } from '../types/public'; export async function clearPassportData(selfClient: SelfClient) { @@ -35,6 +37,53 @@ export async function clearPassportData(selfClient: SelfClient) { await selfClient.saveDocumentCatalog({ documents: [] }); } +/** + * Extract name from a document by loading its full data. + * Works for both MRZ documents (passport/ID card) and Aadhaar documents. + * + * @param selfClient - The SelfClient instance + * @param documentId - The document ID to extract name from + * @returns Object with firstName and lastName, or null if extraction fails + */ +export async function extractNameFromDocument( + selfClient: SelfClient, + documentId: string, +): Promise<{ firstName: string; lastName: string } | null> { + try { + const document = await selfClient.loadDocumentById(documentId); + if (!document) { + return null; + } + + // For Aadhaar documents, extract name from extractedFields + if (isAadhaarDocument(document)) { + const name = document.extractedFields?.name; + if (name && typeof name === 'string') { + // Aadhaar name is typically "FIRSTNAME LASTNAME" format + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + const firstName = parts[0]; + const lastName = parts.slice(1).join(' '); + return { firstName, lastName }; + } else if (parts.length === 1) { + return { firstName: parts[0], lastName: '' }; + } + } + return null; + } + + // For MRZ documents (passport/ID card), extract from MRZ string + if (isMRZDocument(document)) { + return extractNameFromMRZ(document.mrz); + } + + return null; + } catch (error) { + console.error('Error extracting name from document:', error); + return null; + } +} + /** * Gets all documents from the document catalog. * @@ -219,6 +268,14 @@ export async function updateDocumentRegistrationState( if (documentIndex !== -1) { catalog.documents[documentIndex].isRegistered = isRegistered; + // Set registration timestamp when marking as registered + if (isRegistered) { + catalog.documents[documentIndex].registeredAt = Date.now(); + } else { + // Clear timestamp when unregistering + catalog.documents[documentIndex].registeredAt = undefined; + } + await selfClient.saveDocumentCatalog(catalog); console.log(`Updated registration state for document ${documentId}: ${isRegistered}`); diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts index cfdd10fbf..03e8e5345 100644 --- a/packages/mobile-sdk-alpha/src/index.ts +++ b/packages/mobile-sdk-alpha/src/index.ts @@ -90,6 +90,9 @@ export { defaultConfig } from './config/defaults'; export { extractMRZInfo, extractNameFromMRZ, formatDateToYYMMDD, scanMRZ } from './mrz'; +// Document utils +export { extractNameFromDocument } from './documents/utils'; + export { generateMockDocument, signatureAlgorithmToStrictSignatureAlgorithm } from './mock/generator'; // Core functions diff --git a/packages/mobile-sdk-alpha/src/processing/mrz.ts b/packages/mobile-sdk-alpha/src/processing/mrz.ts index 63c1e521a..37f9f77aa 100644 --- a/packages/mobile-sdk-alpha/src/processing/mrz.ts +++ b/packages/mobile-sdk-alpha/src/processing/mrz.ts @@ -263,10 +263,16 @@ export function extractNameFromMRZ(mrzString: string): { firstName: string; last .filter(Boolean); // Handle single-line MRZ strings (common for stored data) - // TD3 format: 88 or 90 characters total (2 lines of 44 or 45 chars each) if (lines.length === 1) { const mrzLength = lines[0].length; - if (mrzLength === 88 || mrzLength === 90) { + + // TD1 format (ID card): 90 characters = 3 lines × 30 chars + // Detect TD1 by checking if it starts with 'I' (ID card) or 'A' (type A) or 'C' (type C) + if (mrzLength === 90 && /^[IAC][ "git@github.com:selfxyz/NFCPassportReader.git" + pod "NFCPassportReader", :git => "git@github.com:selfxyz/NFCPassportReader.git", :commit => "9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b" post_install do |installer| # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 diff --git a/packages/mobile-sdk-demo/ios/Podfile.lock b/packages/mobile-sdk-demo/ios/Podfile.lock index 884c3394b..8c806496a 100644 --- a/packages/mobile-sdk-demo/ios/Podfile.lock +++ b/packages/mobile-sdk-demo/ios/Podfile.lock @@ -1659,7 +1659,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNCPicker (2.11.2): + - RNKeychain (10.0.0): - DoubleConversion - glog - hermes-engine @@ -1680,7 +1680,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNSVG (15.13.0): + - RNSVG (15.12.1): - DoubleConversion - glog - hermes-engine @@ -1700,9 +1700,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNSVG/common (= 15.13.0) + - RNSVG/common (= 15.12.1) - Yoga - - RNSVG/common (15.13.0): + - RNSVG/common (15.12.1): - DoubleConversion - glog - hermes-engine @@ -1756,7 +1756,7 @@ DEPENDENCIES: - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - "mobile-sdk-alpha (from `../node_modules/@selfxyz/mobile-sdk-alpha`)" - - "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`)" + - "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`)" - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) @@ -1818,7 +1818,7 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" + - RNKeychain (from `../node_modules/react-native-keychain`) - RNSVG (from `../node_modules/react-native-svg`) - RNVectorIcons (from `../node_modules/react-native-vector-icons`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -1849,6 +1849,7 @@ EXTERNAL SOURCES: mobile-sdk-alpha: :path: "../node_modules/@selfxyz/mobile-sdk-alpha" NFCPassportReader: + :commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b :git: "git@github.com:selfxyz/NFCPassportReader.git" RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" @@ -1968,8 +1969,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" - RNCPicker: - :path: "../node_modules/@react-native-picker/picker" + RNKeychain: + :path: "../node_modules/react-native-keychain" RNSVG: :path: "../node_modules/react-native-svg" RNVectorIcons: @@ -1979,7 +1980,7 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: NFCPassportReader: - :commit: 04ede227cbfd377e2b4bc9b38f9a89eebdcab52f + :commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b :git: "git@github.com:selfxyz/NFCPassportReader.git" SPEC CHECKSUMS: @@ -2054,12 +2055,12 @@ SPEC CHECKSUMS: ReactCodegen: f853a20cc9125c5521c8766b4b49375fec20648b ReactCommon: 300d8d9c5cb1a6cd79a67cf5d8f91e4d477195f9 RNCAsyncStorage: 87a74d13ba0128f853817e45e21c4051e1f2cd45 - RNCPicker: 31b0c81be6b949dbd8d0c8802e9c6b9615de880a - RNSVG: c22ddda11213ee91192ab2f70b50c78a8bbc30d8 + RNKeychain: 850638785745df5f70c37251130617a66ec82102 + RNSVG: 8dd938fb169dd81009b74c2334780d7d2a04a373 RNVectorIcons: c95fdae217b0ed388f2b4d7ed7a4edc457c1df47 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a -PODFILE CHECKSUM: 22f8edb659097ec6a47366d55dcd021f5b88ccdb +PODFILE CHECKSUM: 7bafdc4607a2a09088e9b68be33648f72b535141 COCOAPODS: 1.16.2 diff --git a/packages/mobile-sdk-demo/package.json b/packages/mobile-sdk-demo/package.json index a0e603ad2..a95b4cd84 100644 --- a/packages/mobile-sdk-demo/package.json +++ b/packages/mobile-sdk-demo/package.json @@ -30,7 +30,6 @@ "@faker-js/faker": "^10.0.0", "@noble/hashes": "^1.5.0", "@react-native-async-storage/async-storage": "^2.2.0", - "@react-native-picker/picker": "^2.11.1", "@react-native/gradle-plugin": "0.76.9", "@selfxyz/common": "workspace:*", "@selfxyz/mobile-sdk-alpha": "workspace:*", @@ -44,7 +43,7 @@ "react-native-get-random-values": "^1.11.0", "react-native-keychain": "^10.0.0", "react-native-safe-area-context": "^5.6.1", - "react-native-svg": "^15.13.0", + "react-native-svg": "15.12.1", "react-native-vector-icons": "^10.3.0", "stream-browserify": "^3.0.0", "util": "^0.12.5" diff --git a/packages/mobile-sdk-demo/src/components/AlgorithmCountryFields.tsx b/packages/mobile-sdk-demo/src/components/AlgorithmCountryFields.tsx new file mode 100644 index 000000000..a1bdab839 --- /dev/null +++ b/packages/mobile-sdk-demo/src/components/AlgorithmCountryFields.tsx @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { countryCodes } from '@selfxyz/common'; +import { signatureAlgorithmToStrictSignatureAlgorithm } from '@selfxyz/mobile-sdk-alpha'; +import { PickerField } from './PickerField'; + +const algorithmOptions = Object.keys(signatureAlgorithmToStrictSignatureAlgorithm); +const countryOptions = Object.keys(countryCodes); + +export function AlgorithmCountryFields({ + show, + algorithm, + setAlgorithm, + country, + setCountry, +}: { + show: boolean; + algorithm: string; + setAlgorithm: (value: string) => void; + country: string; + setCountry: (value: string) => void; +}) { + if (!show) return null; + return ( + <> + ({ label: alg, value: alg }))} + /> + ({ + label: `${code} - ${countryCodes[code as keyof typeof countryCodes]}`, + value: code, + }))} + /> + + ); +} diff --git a/packages/mobile-sdk-demo/src/components/PickerField.tsx b/packages/mobile-sdk-demo/src/components/PickerField.tsx new file mode 100644 index 000000000..7c851b7f8 --- /dev/null +++ b/packages/mobile-sdk-demo/src/components/PickerField.tsx @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { SimplePicker } from './SimplePicker'; +import type { PickerItem } from './SimplePicker'; + +export { type PickerItem }; + +export function PickerField({ + label, + selectedValue, + onValueChange, + items, + enabled = true, +}: { + label: string; + selectedValue: string; + onValueChange: (value: string) => void; + items: PickerItem[]; + enabled?: boolean; +}) { + return ( + + {label} + + + ); +} + +const styles = StyleSheet.create({ + inputContainer: { + marginBottom: 10, + }, + label: { + marginBottom: 4, + fontWeight: '600', + color: '#333', + fontSize: 14, + }, +}); diff --git a/packages/mobile-sdk-demo/src/components/ScreenLayout.tsx b/packages/mobile-sdk-demo/src/components/ScreenLayout.tsx index cbc14fa29..751b65b79 100644 --- a/packages/mobile-sdk-demo/src/components/ScreenLayout.tsx +++ b/packages/mobile-sdk-demo/src/components/ScreenLayout.tsx @@ -13,12 +13,13 @@ type Props = { onBack: () => void; children: React.ReactNode; contentStyle?: ViewStyle; + rightAction?: React.ReactNode; }; -export default function ScreenLayout({ title, onBack, children, contentStyle }: Props) { +export default function ScreenLayout({ title, onBack, children, contentStyle, rightAction }: Props) { return ( - + {children} ); diff --git a/packages/mobile-sdk-demo/src/components/SimplePicker.tsx b/packages/mobile-sdk-demo/src/components/SimplePicker.tsx new file mode 100644 index 000000000..f1742f5b6 --- /dev/null +++ b/packages/mobile-sdk-demo/src/components/SimplePicker.tsx @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { useState } from 'react'; +import { FlatList, Modal, Pressable, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; + +export type PickerItem = { label: string; value: string }; + +type SimplePickerProps = { + items: PickerItem[]; + selectedValue: string; + onValueChange: (value: string) => void; + enabled?: boolean; +}; + +export function SimplePicker({ items, selectedValue, onValueChange, enabled = true }: SimplePickerProps) { + const [modalVisible, setModalVisible] = useState(false); + const selectedItem = items.find(item => item.value === selectedValue); + + const handleSelect = (value: string) => { + onValueChange(value); + setModalVisible(false); + }; + + return ( + <> + enabled && setModalVisible(true)} + style={[styles.pickerPressable, !enabled && styles.disabled]} + > + {selectedItem?.label || 'Select...'} + + + + setModalVisible(false)} + > + + + item.value} + renderItem={({ item }) => ( + handleSelect(item.value)}> + {item.label} + {item.value === selectedValue && } + + )} + /> + setModalVisible(false)}> + Close + + + + + + ); +} + +const styles = StyleSheet.create({ + pickerPressable: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 6, + backgroundColor: '#fff', + paddingHorizontal: 12, + height: 44, + }, + pickerText: { + color: '#000', + fontSize: 14, + }, + disabled: { + backgroundColor: '#f0f0f0', + }, + modalContainer: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + modalContent: { + backgroundColor: '#fff', + borderTopLeftRadius: 10, + borderTopRightRadius: 10, + maxHeight: '50%', + }, + optionItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 15, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + optionText: { + fontSize: 16, + }, + closeButton: { + padding: 15, + alignItems: 'center', + borderTopWidth: 1, + borderTopColor: '#eee', + }, + closeButtonText: { + fontSize: 16, + color: '#007AFF', + fontWeight: '600', + }, +}); diff --git a/packages/mobile-sdk-demo/src/components/StandardHeader.tsx b/packages/mobile-sdk-demo/src/components/StandardHeader.tsx index 65233d737..ec99ea264 100644 --- a/packages/mobile-sdk-demo/src/components/StandardHeader.tsx +++ b/packages/mobile-sdk-demo/src/components/StandardHeader.tsx @@ -9,15 +9,19 @@ import Icon from 'react-native-vector-icons/Ionicons'; type Props = { title: string; onBack: () => void; + rightAction?: React.ReactNode; }; -export default function StandardHeader({ title, onBack }: Props) { +export default function StandardHeader({ title, onBack, rightAction }: Props) { return ( - - - Back - + + + + Back + + {rightAction} + {title} ); @@ -27,13 +31,18 @@ const styles = StyleSheet.create({ header: { marginBottom: 16, }, + topRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, backButton: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 12, - marginBottom: 8, marginLeft: -12, }, backButtonText: { diff --git a/packages/mobile-sdk-demo/src/hooks/useDocuments.ts b/packages/mobile-sdk-demo/src/hooks/useDocuments.ts index 928c7ba7d..d347564ab 100644 --- a/packages/mobile-sdk-demo/src/hooks/useDocuments.ts +++ b/packages/mobile-sdk-demo/src/hooks/useDocuments.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; -import type { DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js'; +import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js'; import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { updateAfterDelete } from '../lib/catalog'; @@ -20,13 +20,24 @@ export function useDocuments() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [deleting, setDeleting] = useState(null); + const [clearing, setClearing] = useState(false); const refresh = useCallback(async () => { setLoading(true); setError(null); try { const all = await getAllDocuments(selfClient); - setDocuments(Object.values(all)); + const sortedDocuments = Object.values(all).sort((a, b) => { + // Registered documents first + if (a.metadata.isRegistered && !b.metadata.isRegistered) { + return -1; + } + if (!a.metadata.isRegistered && b.metadata.isRegistered) { + return 1; + } + return 0; + }); + setDocuments(sortedDocuments); } catch (err) { setDocuments([]); setError(err instanceof Error ? err.message : String(err)); @@ -55,5 +66,42 @@ export function useDocuments() { [selfClient, refresh], ); - return { documents, loading, error, deleting, refresh, deleteDocument } as const; + const clearAllDocuments = useCallback(async () => { + setClearing(true); + setError(null); + let originalCatalog: DocumentCatalog | null = null; + try { + // Read and persist the existing catalog. + originalCatalog = await selfClient.loadDocumentCatalog(); + const docIds = originalCatalog.documents.map(d => d.id); + + // Write an empty catalog to atomically remove references. + const emptyCatalog = { + documents: [], + selectedDocumentId: undefined, + }; + await selfClient.saveDocumentCatalog(emptyCatalog); + + try { + // Then perform deletions of document ids from storage. + for (const docId of docIds) { + await selfClient.deleteDocument(docId); + } + } catch (deletionError) { + // If any deletion fails, restore the previous catalog and re-throw. + if (originalCatalog) { + await selfClient.saveDocumentCatalog(originalCatalog); + } + throw deletionError; // Re-throw to be caught by the outer catch block. + } + + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setClearing(false); + } + }, [selfClient, refresh]); + + return { documents, loading, error, deleting, clearing, refresh, deleteDocument, clearAllDocuments } as const; } diff --git a/packages/mobile-sdk-demo/src/hooks/useRegistration.ts b/packages/mobile-sdk-demo/src/hooks/useRegistration.ts index a3f6112a1..898e97501 100644 --- a/packages/mobile-sdk-demo/src/hooks/useRegistration.ts +++ b/packages/mobile-sdk-demo/src/hooks/useRegistration.ts @@ -23,6 +23,7 @@ export function useRegistration() { const init = useProvingStore(state => state.init); const setUserConfirmed = useProvingStore(state => state.setUserConfirmed); const autoConfirmTimer = useRef(); + const onCompleteRef = useRef void)>(null); const [registering, setRegistering] = useState(false); const [statusMessage, setStatusMessage] = useState(''); @@ -45,6 +46,24 @@ export function useRegistration() { return () => unsubscribe(); }, [selfClient, registering, addLog]); + // Also listen for explicit SDK success event as a reliable completion signal + useEffect(() => { + if (!registering) return; + const unsubscribe = selfClient.on(SdkEvents.PROVING_ACCOUNT_VERIFIED_SUCCESS, () => { + setStatusMessage('🎉 Registration completed successfully!'); + addLog('Document registered on-chain! (event)', 'info'); + if (onCompleteRef.current) { + try { + onCompleteRef.current(); + } finally { + onCompleteRef.current = null; + } + } + setRegistering(false); + }); + return () => unsubscribe(); + }, [selfClient, registering, addLog]); + useEffect(() => { if (!registering) return; switch (currentState) { @@ -88,6 +107,13 @@ export function useRegistration() { setStatusMessage('🎉 Registration completed successfully!'); addLog('Document registered on-chain!', 'info'); setRegistering(false); + if (onCompleteRef.current) { + try { + onCompleteRef.current(); + } finally { + onCompleteRef.current = null; // ensure one-shot + } + } break; case 'error': case 'failure': @@ -122,12 +148,18 @@ export function useRegistration() { state: { registering, statusMessage, currentState, logs, showLogs }, actions: { start, + setOnComplete: (cb: (() => void) | null) => { + onCompleteRef.current = cb; + }, toggleLogs: () => setShowLogs(s => !s), reset: () => { setRegistering(false); setStatusMessage(''); setLogs([]); setShowLogs(false); + onCompleteRef.current = null; + // Reset the SDK's proving store state to prevent stale 'completed' state + useProvingStore.setState({ currentState: 'idle' }); }, }, } as const; diff --git a/packages/mobile-sdk-demo/src/providers/SelfClientProvider.tsx b/packages/mobile-sdk-demo/src/providers/SelfClientProvider.tsx index e1cf76339..76665d6bd 100644 --- a/packages/mobile-sdk-demo/src/providers/SelfClientProvider.tsx +++ b/packages/mobile-sdk-demo/src/providers/SelfClientProvider.tsx @@ -29,19 +29,84 @@ const createFetch = () => { return (input: RequestInfo | URL, init?: RequestInit) => fetchImpl(input, init); }; -const createWsAdapter = () => ({ - connect: (_url: string): WsConn => { +const createWsAdapter = () => { + const WebSocketImpl = globalThis.WebSocket; + + if (!WebSocketImpl) { return { - send: () => { - throw new Error('WebSocket send is not implemented in the demo environment.'); + connect: () => { + throw new Error('WebSocket is not available in this environment. Provide a WebSocket implementation.'); }, - close: () => {}, - onMessage: () => {}, - onError: () => {}, - onClose: () => {}, }; - }, -}); + } + + return { + connect: (url: string, opts?: { signal?: AbortSignal; headers?: Record }): WsConn => { + const socket = new WebSocketImpl(url); + + let abortHandler: (() => void) | null = null; + + if (opts?.signal) { + abortHandler = () => { + socket.close(); + }; + + if (typeof opts.signal.addEventListener === 'function') { + opts.signal.addEventListener('abort', abortHandler, { once: true }); + } + } + + const attach = (event: 'message' | 'error' | 'close', handler: (payload?: any) => void) => { + // Clean up abort listener when socket closes + if (event === 'close' && abortHandler && opts?.signal) { + const originalHandler = handler; + handler = (payload?: any) => { + if (typeof opts.signal!.removeEventListener === 'function') { + opts.signal!.removeEventListener('abort', abortHandler!); + } + originalHandler(payload); + }; + } + + if (typeof socket.addEventListener === 'function') { + if (event === 'message') { + (socket.addEventListener as any)('message', handler as any); + } else if (event === 'error') { + (socket.addEventListener as any)('error', handler as any); + } else { + (socket.addEventListener as any)('close', handler as any); + } + } else { + if (event === 'message') { + (socket as any).onmessage = handler; + } else if (event === 'error') { + (socket as any).onerror = handler; + } else { + (socket as any).onclose = handler; + } + } + }; + + return { + send: (data: string | ArrayBufferView | ArrayBuffer) => socket.send(data), + close: () => socket.close(), + onMessage: cb => { + attach('message', event => { + // React Native emits { data }, whereas browsers emit MessageEvent. + const payload = (event as { data?: unknown }).data ?? event; + cb(payload); + }); + }, + onError: cb => { + attach('error', error => cb(error)); + }, + onClose: cb => { + attach('close', () => cb()); + }, + }; + }, + }; +}; const hash = (data: Uint8Array): Uint8Array => sha256(data); @@ -74,7 +139,9 @@ export function SelfClientProvider({ children }: PropsWithChildren) { auth: { async getPrivateKey(): Promise { try { - return await getOrCreateSecret(); + const secret = await getOrCreateSecret(); + // Ensure the secret is 0x-prefixed for components expecting hex strings + return secret.startsWith('0x') ? secret : `0x${secret}`; } catch (error) { console.error('Failed to get/create secret:', error); return null; diff --git a/packages/mobile-sdk-demo/src/screens/DocumentsList.tsx b/packages/mobile-sdk-demo/src/screens/DocumentsList.tsx index a1963301c..1184392eb 100644 --- a/packages/mobile-sdk-demo/src/screens/DocumentsList.tsx +++ b/packages/mobile-sdk-demo/src/screens/DocumentsList.tsx @@ -2,11 +2,11 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import type { DocumentCatalog } from '@selfxyz/common/dist/esm/src/utils/types.js'; -// no direct SDK calls here +import { extractNameFromDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import ScreenLayout from '../components/ScreenLayout'; import { formatDataPreview, humanizeDocumentType, maskId } from '../utils/document'; @@ -22,13 +22,63 @@ type Props = { // helpers moved to utils/document export default function DocumentsList({ onBack, catalog }: Props) { - const { documents, loading, error, deleting, deleteDocument, refresh } = useDocuments(); + const selfClient = useSelfClient(); + const { documents, loading, error, deleting, deleteDocument, refresh, clearing, clearAllDocuments } = useDocuments(); + const [documentNames, setDocumentNames] = useState>({}); // Refresh when catalog selection changes (e.g., after generation or external updates) useEffect(() => { refresh(); }, [catalog.selectedDocumentId, refresh]); + // Load names for all documents + useEffect(() => { + let cancelled = false; + + const loadDocumentNames = async () => { + const names: Record = {}; + await Promise.all( + documents.map(async doc => { + const name = await extractNameFromDocument(selfClient, doc.metadata.id); + if (name) { + names[doc.metadata.id] = name; + } + }), + ); + if (!cancelled) { + setDocumentNames(names); + } + }; + + if (documents.length === 0) { + setDocumentNames({}); + return; + } + + loadDocumentNames(); + + return () => { + cancelled = true; + }; + }, [documents, selfClient]); + + const handleClearAll = () => { + Alert.alert('Clear All Documents', 'Are you sure you want to delete all documents? This action cannot be undone.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear All', + style: 'destructive', + onPress: async () => { + try { + await clearAllDocuments(); + } catch (err) { + Alert.alert('Error', `Failed to clear documents: ${err instanceof Error ? err.message : String(err)}`); + } + }, + }, + ]); + }; + const handleDelete = async (documentId: string, documentType: string) => { Alert.alert('Delete Document', `Are you sure you want to delete this ${humanizeDocumentType(documentType)}?`, [ { text: 'Cancel', style: 'cancel' }, @@ -68,10 +118,9 @@ export default function DocumentsList({ onBack, catalog }: Props) { if (documents.length === 0) { return ( - No documents yet + No documents - Generate a mock document to see it appear here. The demo document store keeps everything locally on your - device. + Generate a mock document or scan a real document to see it appear here. ); @@ -83,11 +132,16 @@ export default function DocumentsList({ onBack, catalog }: Props) { const preview = formatDataPreview(metadata); const documentId = maskId(metadata.id); const isDeleting = deleting === metadata.id; + const nameData = documentNames[metadata.id]; + const fullName = nameData ? `${nameData.firstName} ${nameData.lastName}`.trim() : null; return ( - {humanizeDocumentType(metadata.documentType)} + + {humanizeDocumentType(metadata.documentType)} + {fullName && {fullName}} + {statusLabel} @@ -105,7 +159,6 @@ export default function DocumentsList({ onBack, catalog }: Props) { - {(metadata.documentCategory ?? 'unknown').toUpperCase()} {metadata.mock ? 'Mock data' : 'Live data'} {preview} @@ -115,10 +168,24 @@ export default function DocumentsList({ onBack, catalog }: Props) { ); }); - }, [documents, error, loading, deleting]); + }, [documents, error, loading, deleting, documentNames]); + + const clearButton = ( + + {clearing ? ( + + ) : ( + Clear All + )} + + ); return ( - + {content} ); @@ -131,6 +198,33 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, paddingVertical: 20, }, + headerContainer: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginBottom: 10, + }, + clearButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: '#ffeef0', + borderWidth: 1, + borderColor: '#dc3545', + alignItems: 'center', + justifyContent: 'center', + minHeight: 30, + minWidth: 80, + alignSelf: 'flex-end', + }, + clearButtonText: { + color: '#dc3545', + fontWeight: '600', + fontSize: 14, + }, + disabledButton: { + backgroundColor: '#f8f9fa', + borderColor: '#e1e5e9', + }, content: { flex: 1, }, @@ -153,11 +247,20 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', marginBottom: 8, }, + documentTitleContainer: { + flex: 1, + marginRight: 12, + }, documentType: { fontSize: 18, fontWeight: '600', color: '#333', - flex: 1, + }, + documentName: { + fontSize: 15, + fontWeight: '500', + color: '#0550ae', + marginTop: 2, }, headerRight: { alignItems: 'flex-end', diff --git a/packages/mobile-sdk-demo/src/screens/GenerateMock.tsx b/packages/mobile-sdk-demo/src/screens/GenerateMock.tsx index 28b752872..dc9cff6e1 100644 --- a/packages/mobile-sdk-demo/src/screens/GenerateMock.tsx +++ b/packages/mobile-sdk-demo/src/screens/GenerateMock.tsx @@ -3,25 +3,20 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import React, { useState } from 'react'; -import { ActivityIndicator, Button, Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { ActivityIndicator, Alert, Button, Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; import { faker } from '@faker-js/faker'; -import { calculateContentHash, countryCodes, inferDocumentCategory, isMRZDocument } from '@selfxyz/common'; +import { calculateContentHash, inferDocumentCategory, isMRZDocument } from '@selfxyz/common'; import type { DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js'; -import { - generateMockDocument, - signatureAlgorithmToStrictSignatureAlgorithm, - useSelfClient, -} from '@selfxyz/mobile-sdk-alpha'; +import { generateMockDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; -import { Picker } from '@react-native-picker/picker'; -import Icon from 'react-native-vector-icons/Ionicons'; import SafeAreaScrollView from '../components/SafeAreaScrollView'; import StandardHeader from '../components/StandardHeader'; +import { AlgorithmCountryFields } from '../components/AlgorithmCountryFields'; +import { PickerField } from '../components/PickerField'; -const algorithmOptions = Object.keys(signatureAlgorithmToStrictSignatureAlgorithm); const documentTypeOptions = ['mock_passport', 'mock_id_card', 'mock_aadhaar'] as const; -const countryOptions = Object.keys(countryCodes); +const documentTypePickerItems = documentTypeOptions.map(dt => ({ label: dt, value: dt })); const defaultAge = '21'; const defaultExpiryYears = '5'; @@ -68,6 +63,9 @@ export default function GenerateMock({ onDocumentStored, onNavigate, onBack }: P const handleGenerate = async () => { setLoading(true); setError(null); + // Force React to render the loading state before starting async work + await new Promise(resolve => setTimeout(resolve, 0)); + const startTime = Date.now(); try { const ageNum = Number(age); const expiryNum = Number(expiryYears); @@ -111,8 +109,23 @@ export default function GenerateMock({ onDocumentStored, onNavigate, onBack }: P catalog.selectedDocumentId = documentId; await selfClient.saveDocumentCatalog(catalog); await onDocumentStored?.(); - // Auto-navigate to register screen after successful generation - onNavigate('register'); + + // Refresh first and last name with new random values after successful generation + setFirstName(getRandomFirstName()); + setLastName(getRandomLastName()); + + // Ensure minimum loading display time (500ms) for better UX + const elapsed = Date.now() - startTime; + if (elapsed < 500) { + await new Promise(resolve => setTimeout(resolve, 500 - elapsed)); + } + + // Auto-navigate to register screen only if it's the first document + if (catalog.documents.length === 1) { + onNavigate('register'); + } else { + Alert.alert('Success', 'Mock document generated successfully.'); + } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -161,65 +174,19 @@ export default function GenerateMock({ onDocumentStored, onNavigate, onBack }: P ios_backgroundColor="#d1d5db" /> - {documentType !== 'mock_aadhaar' && ( - <> - - Algorithm - - setAlgorithm(itemValue)} - style={styles.picker} - > - {algorithmOptions.map(alg => ( - - ))} - - {Platform.OS === 'ios' && ( - - )} - - - - Country - - setCountry(itemValue)} - style={styles.picker} - > - {countryOptions.map(code => ( - - ))} - - {Platform.OS === 'ios' && ( - - )} - - - - )} - - Document Type - - setDocumentType(itemValue as (typeof documentTypeOptions)[number])} - style={styles.picker} - > - {documentTypeOptions.map(dt => ( - - ))} - - {Platform.OS === 'ios' && ( - - )} - - + + setDocumentType(itemValue as (typeof documentTypeOptions)[number])} + items={documentTypePickerItems} + />