From 0a04f446824a7908cbe177a42e6524f1faa3a91b Mon Sep 17 00:00:00 2001 From: turnoffthiscomputer <98749896+remicolin@users.noreply.github.com> Date: Thu, 2 Oct 2025 19:27:27 +0200 Subject: [PATCH] =?UTF-8?q?Add=20LoadingUI=20and=20DevLoadingScreen=20comp?= =?UTF-8?q?onents=20for=20enhanced=20loading=20ex=E2=80=A6=20(#1189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add LoadingUI and DevLoadingScreen components for enhanced loading experience - Introduced LoadingUI component with customizable animation, action text, and progress indicators. - Created DevLoadingScreen to facilitate development testing with state selection and loading feedback. - Updated navigation to include DevLoadingScreen in the dev tools. - Adjusted loading screen logic to improve user experience during document processing. - Enhanced loading text management to provide clearer feedback based on current state. - Added new SVG icon for the loading UI. This commit improves the overall loading experience in the app, making it more user-friendly and visually appealing. * Update navigation tests to include DevLoadingScreen for improved development experience --- app/src/components/loading/LoadingUI.tsx | 227 +++++++++++++++ app/src/images/icons/plus_slate600.svg | 3 + app/src/navigation/devTools.ts | 8 + app/src/providers/selfClientProvider.tsx | 2 +- app/src/screens/dev/DevLoadingScreen.tsx | 271 ++++++++++++++++++ app/src/screens/dev/DevSettingsScreen.tsx | 1 + .../document/DocumentNFCScanScreen.tsx | 80 +----- .../screens/prove/ConfirmBelongingScreen.tsx | 20 +- app/src/screens/system/Loading.tsx | 159 +++++----- app/src/utils/haptic/index.ts | 37 +-- .../utils/proving/loadingScreenStateText.ts | 92 ++++-- app/tests/src/navigation.test.ts | 1 + .../src/constants/analytics.ts | 1 + packages/mobile-sdk-alpha/src/index.ts | 3 + .../src/proving/provingMachine.ts | 121 +++++++- 15 files changed, 802 insertions(+), 224 deletions(-) create mode 100644 app/src/components/loading/LoadingUI.tsx create mode 100644 app/src/images/icons/plus_slate600.svg create mode 100644 app/src/screens/dev/DevLoadingScreen.tsx diff --git a/app/src/components/loading/LoadingUI.tsx b/app/src/components/loading/LoadingUI.tsx new file mode 100644 index 000000000..7e79cab75 --- /dev/null +++ b/app/src/components/loading/LoadingUI.tsx @@ -0,0 +1,227 @@ +// 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 LottieView from 'lottie-react-native'; +import React from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Text, View, XStack, YStack } from 'tamagui'; + +import CloseWarningIcon from '@/images/icons/close-warning.svg'; +import Plus from '@/images/icons/plus_slate600.svg'; +import { + black, + blue600, + cyan300, + red500, + slate400, + slate600, + white, + zinc500, + zinc900, +} from '@/utils/colors'; +import { extraYPadding } from '@/utils/constants'; +import { advercase, dinot } from '@/utils/fonts'; + +interface LoadingUIProps { + animationSource: LottieView['props']['source']; + shouldLoopAnimation: boolean; + actionText: string; + actionSubText: string; + estimatedTime: string; + canCloseApp: boolean; + statusBarProgress: number; +} + +const LoadingUI: React.FC = ({ + animationSource, + shouldLoopAnimation, + actionText, + actionSubText, + estimatedTime, + canCloseApp, + statusBarProgress, +}) => { + const { bottom } = useSafeAreaInsets(); + + const renderProgressBars = () => { + const bars = []; + for (let i = 0; i < 3; i++) { + bars.push( + , + ); + } + bars.push( + + + , + ); + for (let i = 3; i < 6; i++) { + bars.push( + 1 + ? slate600 + : 'transparent' + } + />, + ); + } + + return bars; + }; + + return ( + + + + + + + {actionText} + + + + {renderProgressBars()} + + + {actionSubText.toUpperCase()} + + + {6 - statusBarProgress} Steps Remaining + + + + + + + ESTIMATED TIME: + + + {estimatedTime} + + + + + + + + {canCloseApp + ? 'You can now safely close the app' + : 'Closing the app will cancel this process'} + + + + + ); +}; + +export default LoadingUI; diff --git a/app/src/images/icons/plus_slate600.svg b/app/src/images/icons/plus_slate600.svg new file mode 100644 index 000000000..3ef84e60b --- /dev/null +++ b/app/src/images/icons/plus_slate600.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/navigation/devTools.ts b/app/src/navigation/devTools.ts index 2a198bdbf..2de385f3a 100644 --- a/app/src/navigation/devTools.ts +++ b/app/src/navigation/devTools.ts @@ -8,6 +8,7 @@ import CreateMockScreen from '@/screens/dev/CreateMockScreen'; import CreateMockScreenDeepLink from '@/screens/dev/CreateMockScreenDeepLink'; import DevFeatureFlagsScreen from '@/screens/dev/DevFeatureFlagsScreen'; import DevHapticFeedbackScreen from '@/screens/dev/DevHapticFeedbackScreen'; +import DevLoadingScreen from '@/screens/dev/DevLoadingScreen'; import DevPrivateKeyScreen from '@/screens/dev/DevPrivateKeyScreen'; import DevSettingsScreen from '@/screens/dev/DevSettingsScreen'; import { black, white } from '@/utils/colors'; @@ -71,6 +72,13 @@ const devScreens = { title: 'Private Key', } as NativeStackNavigationOptions, }, + DevLoadingScreen: { + screen: DevLoadingScreen, + options: { + ...devHeaderOptions, + title: 'Dev Loading Screen', + } as NativeStackNavigationOptions, + }, }; export default devScreens; diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index c981395c7..ee4fbc8db 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -114,7 +114,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { if (navigationRef.isReady()) { navigationRef.navigate('AccountVerifiedSuccess'); } - }, 3000); + }, 1000); }); addListener( diff --git a/app/src/screens/dev/DevLoadingScreen.tsx b/app/src/screens/dev/DevLoadingScreen.tsx new file mode 100644 index 000000000..4b18f43e9 --- /dev/null +++ b/app/src/screens/dev/DevLoadingScreen.tsx @@ -0,0 +1,271 @@ +// 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 LottieView from 'lottie-react-native'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Adapt, Button, Select, Sheet, Text, XStack, YStack } from 'tamagui'; +import { Check, ChevronDown } from '@tamagui/lucide-icons'; + +import { + provingMachineCircuitType, + ProvingStateType, +} from '@selfxyz/mobile-sdk-alpha'; + +import failAnimation from '@/assets/animations/loading/fail.json'; +import proveLoadingAnimation from '@/assets/animations/loading/prove.json'; +import LoadingUI from '@/components/loading/LoadingUI'; +import { slate200, slate500 } from '@/utils/colors'; +import { dinot } from '@/utils/fonts'; +import { getLoadingScreenText } from '@/utils/proving/loadingScreenStateText'; + +const allProvingStates = [ + 'idle', + 'parsing_id_document', + 'fetching_data', + 'validating_document', + 'init_tee_connexion', + 'listening_for_status', + 'ready_to_prove', + 'proving', + 'post_proving', + 'completed', + 'error', + 'failure', + 'passport_not_supported', + 'account_recovery_choice', + 'passport_data_not_found', +] as const; + +const DevLoadingScreen: React.FC = () => { + const [currentState, setCurrentState] = useState('idle'); + const [documentType, setDocumentType] = + useState('dsc'); + const [animationSource, setAnimationSource] = useState< + LottieView['props']['source'] + >(proveLoadingAnimation); + const [loadingText, setLoadingText] = useState<{ + actionText: string; + actionSubText: string; + estimatedTime: string; + statusBarProgress: number; + }>({ + actionText: '', + actionSubText: '', + estimatedTime: '', + statusBarProgress: 0, + }); + const [canCloseApp, setCanCloseApp] = useState(false); + const [shouldLoopAnimation, setShouldLoopAnimation] = useState(true); + + const terminalStates: ProvingStateType[] = [ + 'completed', + 'error', + 'failure', + 'passport_not_supported', + 'account_recovery_choice', + 'passport_data_not_found', + ]; + + const safeToCloseStates: ProvingStateType[] = [ + 'proving', + 'post_proving', + 'completed', + ]; + + useEffect(() => { + const { actionText, actionSubText, estimatedTime, statusBarProgress } = + getLoadingScreenText(currentState, 'rsa', '65537', documentType); + setLoadingText({ + actionText, + actionSubText, + estimatedTime, + statusBarProgress, + }); + + switch (currentState) { + case 'completed': + break; + case 'error': + case 'failure': + case 'passport_not_supported': + case 'account_recovery_choice': + case 'passport_data_not_found': + setAnimationSource(failAnimation); + break; + default: + setAnimationSource(proveLoadingAnimation); + break; + } + setCanCloseApp(safeToCloseStates.includes(currentState)); + setShouldLoopAnimation(!terminalStates.includes(currentState)); + }, [currentState, documentType]); + + const [open, setOpen] = useState(false); + const [documentTypeOpen, setDocumentTypeOpen] = useState(false); + + return ( + + + {/* State Selector */} + + + {/* Document Type Selector */} + + + + + ); +}; + +export default DevLoadingScreen; diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index f88a40052..f8c5cc2df 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -125,6 +125,7 @@ function ParameterSection({ const items = [ 'DevSettings', 'CountryPicker', + 'DevLoadingScreen', 'AadhaarUpload', 'DevFeatureFlags', 'DevHapticFeedback', diff --git a/app/src/screens/document/DocumentNFCScanScreen.tsx b/app/src/screens/document/DocumentNFCScanScreen.tsx index 316667499..5943a5924 100644 --- a/app/src/screens/document/DocumentNFCScanScreen.tsx +++ b/app/src/screens/document/DocumentNFCScanScreen.tsx @@ -364,7 +364,6 @@ const DocumentNFCScanScreen: React.FC = () => { { duration_seconds: parseFloat(scanDurationSeconds) }, ); let passportData: PassportData | null = null; - let parsedPassportData: PassportData | null = null; try { passportData = parseScanResponse(scanResponse); } catch (e: unknown) { @@ -380,78 +379,17 @@ const DocumentNFCScanScreen: React.FC = () => { }); return; } - try { - const skiPem = await getSKIPEM('production'); - parsedPassportData = initPassportDataParsing(passportData, skiPem); - if (!parsedPassportData) { - throw new Error('Failed to parse passport data'); - } - const passportMetadata = parsedPassportData.passportMetadata!; - let dscObject; - try { - dscObject = { dsc: passportMetadata.dsc }; - } catch (error) { - console.error('Failed to parse dsc:', error); - dscObject = {}; - } - - trackEvent(PassportEvents.PASSPORT_PARSED, { - success: true, - data_groups: passportMetadata.dataGroups, - dg1_size: passportMetadata.dg1Size, - dg1_hash_size: passportMetadata.dg1HashSize, - dg1_hash_function: passportMetadata.dg1HashFunction, - dg1_hash_offset: passportMetadata.dg1HashOffset, - dg_padding_bytes: passportMetadata.dgPaddingBytes, - e_content_size: passportMetadata.eContentSize, - e_content_hash_function: passportMetadata.eContentHashFunction, - e_content_hash_offset: passportMetadata.eContentHashOffset, - signed_attr_size: passportMetadata.signedAttrSize, - signed_attr_hash_function: passportMetadata.signedAttrHashFunction, - signature_algorithm: passportMetadata.signatureAlgorithm, - salt_length: passportMetadata.saltLength, - curve_or_exponent: passportMetadata.curveOrExponent, - signature_algorithm_bits: passportMetadata.signatureAlgorithmBits, - country_code: passportMetadata.countryCode, - csca_found: passportMetadata.cscaFound, - csca_hash_function: passportMetadata.cscaHashFunction, - csca_signature_algorithm: passportMetadata.cscaSignatureAlgorithm, - csca_salt_length: passportMetadata.cscaSaltLength, - csca_curve_or_exponent: passportMetadata.cscaCurveOrExponent, - csca_signature_algorithm_bits: - passportMetadata.cscaSignatureAlgorithmBits, - dsc: dscObject, - dsc_aki: passportData.dsc_parsed?.authorityKeyIdentifier, - dsc_ski: passportData.dsc_parsed?.subjectKeyIdentifier, - }); - if (parsedPassportData) { - await storePassportData(parsedPassportData); - } - // Feels better somehow - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Check if scan was cancelled by timeout before navigating - if (scanCancelledRef.current) { - return; - } - navigation.navigate('ConfirmBelonging', {}); - } catch (e: unknown) { - // Check if scan was cancelled by timeout - if (scanCancelledRef.current) { - return; - } - console.error('Passport Parsed Failed:', e); - const errMsg = sanitizeErrorMessage( - e instanceof Error ? e.message : String(e), - ); - trackEvent(PassportEvents.PASSPORT_PARSE_FAILED, { - error: errMsg, - }); - trackNfcEvent(PassportEvents.PASSPORT_PARSE_FAILED, { - error: errMsg, - }); + if (passportData) { + console.log('Storing passport data from NFC scan...'); + await storePassportData(passportData); + console.log('Passport data stored successfully'); + } + await new Promise(resolve => setTimeout(resolve, 700)); // small delay to let the native NFC sheet close + // Check if scan was cancelled by timeout before navigating + if (scanCancelledRef.current) { return; } + navigation.navigate('ConfirmBelonging', {}); } catch (e: unknown) { // Check if scan was cancelled by timeout if (scanCancelledRef.current) { diff --git a/app/src/screens/prove/ConfirmBelongingScreen.tsx b/app/src/screens/prove/ConfirmBelongingScreen.tsx index 00dfe5dc4..12d889d02 100644 --- a/app/src/screens/prove/ConfirmBelongingScreen.tsx +++ b/app/src/screens/prove/ConfirmBelongingScreen.tsx @@ -4,7 +4,6 @@ import LottieView from 'lottie-react-native'; import React, { useEffect, useState } from 'react'; -import { ActivityIndicator, View } from 'react-native'; import type { StaticScreenProps } from '@react-navigation/native'; import { usePreventRemove } from '@react-navigation/native'; @@ -43,7 +42,6 @@ const ConfirmBelongingScreen: React.FC = () => { params: {}, }); const [_requestingPermission, setRequestingPermission] = useState(false); - const { setUserConfirmed, isReadyToProve } = usePrepareDocumentProof(); const setFcmToken = useSettingStore(state => state.setFcmToken); useEffect(() => { @@ -66,13 +64,9 @@ const ConfirmBelongingScreen: React.FC = () => { } } - // Mark as user confirmed - proving will start automatically when ready - setUserConfirmed(selfClient); - - // Navigate to loading screen navigate(); } catch (error: unknown) { - console.error('Error initializing proving process:', error); + console.error('Error navigating:', error); const message = error instanceof Error ? error.message : 'Unknown error'; trackEvent(ProofEvents.PROVING_PROCESS_ERROR, { error: message, @@ -82,8 +76,6 @@ const ConfirmBelongingScreen: React.FC = () => { }); flushAllAnalytics(); - } finally { - setRequestingPermission(false); } }; @@ -115,16 +107,8 @@ const ConfirmBelongingScreen: React.FC = () => { - {isReadyToProve ? ( - 'Confirm' - ) : ( - - - Preparing verification - - )} + Confirm diff --git a/app/src/screens/system/Loading.tsx b/app/src/screens/system/Loading.tsx index e5bc6614f..0b2177a0b 100644 --- a/app/src/screens/system/Loading.tsx +++ b/app/src/screens/system/Loading.tsx @@ -8,16 +8,15 @@ import { StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text, YStack } from 'tamagui'; import type { StaticScreenProps } from '@react-navigation/native'; -import { useIsFocused } from '@react-navigation/native'; +import { useFocusEffect, useIsFocused } from '@react-navigation/native'; import { IDDocument } from '@selfxyz/common/utils/types'; -import { - type ProvingStateType, - useSelfClient, -} from '@selfxyz/mobile-sdk-alpha'; +import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; +import { ProvingStateType } from '@selfxyz/mobile-sdk-alpha/browser'; import failAnimation from '@/assets/animations/loading/fail.json'; import proveLoadingAnimation from '@/assets/animations/loading/prove.json'; +import LoadingUI from '@/components/loading/LoadingUI'; import CloseWarningIcon from '@/images/icons/close-warning.svg'; import { loadPassportDataAndSecret } from '@/providers/passportDataProvider'; import { useSettingStore } from '@/stores/settingStore'; @@ -42,6 +41,9 @@ const terminalStates: ProvingStateType[] = [ const LoadingScreen: React.FC = ({}) => { const { useProvingStore } = useSelfClient(); + // Track if we're initializing to show clean state + const [isInitializing, setIsInitializing] = useState(false); + // Animation states const [animationSource, setAnimationSource] = useState< LottieView['props']['source'] @@ -53,15 +55,22 @@ const LoadingScreen: React.FC = ({}) => { // Loading text state const [loadingText, setLoadingText] = useState<{ actionText: string; + actionSubText: string; estimatedTime: string; + statusBarProgress: number; }>({ actionText: '', + actionSubText: '', estimatedTime: '', + statusBarProgress: 0, }); - // Get current state from proving machine, default to 'idle' if undefined + // Get proving store and self client + const selfClient = useSelfClient(); const currentState = useProvingStore(state => state.currentState) ?? 'idle'; const fcmToken = useSettingStore(state => state.fcmToken); + const init = useProvingStore(state => state.init); + const circuitType = useProvingStore(state => state.circuitType); const isFocused = useIsFocused(); const { bottom } = useSafeAreaInsets(); @@ -69,6 +78,33 @@ const LoadingScreen: React.FC = ({}) => { const safeToCloseStates = ['proving', 'post_proving', 'completed']; const canCloseApp = safeToCloseStates.includes(currentState); + // Initialize proving process + useEffect(() => { + if (!isFocused) return; + + setIsInitializing(true); + + // Always initialize when screen becomes focused, regardless of current state + // This ensures proper reset between proving sessions + const initializeProving = async () => { + try { + const selectedDocument = await loadSelectedDocument(selfClient); + if (selectedDocument?.data?.documentCategory === 'aadhaar') { + await init(selfClient, 'register', true); + } else { + await init(selfClient, 'dsc', true); + } + } catch (error) { + console.error('Error loading selected document:', error); + await init(selfClient, 'dsc', true); + } finally { + setIsInitializing(false); + } + }; + + initializeProving(); + }, [isFocused, init, selfClient]); + // Initialize notifications and load passport data useEffect(() => { let isMounted = true; @@ -107,14 +143,8 @@ const LoadingScreen: React.FC = ({}) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFocused]); // Only depend on isFocused - // Handle UI updates and haptic feedback based on state changes + // Handle UI updates based on state changes useEffect(() => { - // Stop haptics if screen is not focused - if (!isFocused) { - loadingScreenProgress(false); - return; - } - let { signatureAlgorithm, curveOrExponent } = { signatureAlgorithm: 'rsa', curveOrExponent: '65537', @@ -132,15 +162,27 @@ const LoadingScreen: React.FC = ({}) => { break; // keep the default values for aadhaar } - const { actionText, estimatedTime } = getLoadingScreenText( - currentState as ProvingStateType, - signatureAlgorithm, - curveOrExponent, - ); - setLoadingText({ actionText, estimatedTime }); + // Use clean initial state if we're initializing, otherwise use current state + const displayState = isInitializing ? 'idle' : currentState; + const displayCircuitType = isInitializing ? 'dsc' : circuitType || 'dsc'; - // Update animation based on state - switch (currentState) { + const { actionText, actionSubText, estimatedTime, statusBarProgress } = + getLoadingScreenText( + displayState as ProvingStateType, + signatureAlgorithm, + curveOrExponent, + displayCircuitType, + ); + setLoadingText({ + actionText, + actionSubText, + estimatedTime, + statusBarProgress, + }); + + // Update animation based on state (use clean state if initializing) + const animationState = isInitializing ? 'idle' : currentState; + switch (animationState) { case 'completed': // setAnimationSource(successAnimation); break; @@ -157,21 +199,20 @@ const LoadingScreen: React.FC = ({}) => { setAnimationSource(proveLoadingAnimation); break; } + }, [currentState, fcmToken, passportData, isInitializing]); - // Stop haptics if we're in a terminal state - if (terminalStates.includes(currentState as ProvingStateType)) { - loadingScreenProgress(false); - return; - } + // Handle haptic feedback using useFocusEffect for immediate response + useFocusEffect( + React.useCallback(() => { + // Start haptic feedback as soon as the screen is focused + loadingScreenProgress(true); - // Start haptic feedback for non-terminal states - loadingScreenProgress(true); - - // Cleanup on unmount or state change - return () => { - loadingScreenProgress(false); - }; - }, [currentState, isFocused, fcmToken, passportData]); + // Cleanup function to stop haptics when the screen is unfocused + return () => { + loadingScreenProgress(false); + }; + }, []), + ); // Determine if animation should loop based on terminal states const shouldLoopAnimation = !terminalStates.includes( @@ -179,47 +220,15 @@ const LoadingScreen: React.FC = ({}) => { ); return ( - - - - - - {loadingText.actionText} - - - - - ESTIMATED TIME: - - {loadingText.estimatedTime} - - - - - - - - {canCloseApp - ? 'You can now safely close the app' - : 'Closing the app will cancel this process'} - - - - + ); }; diff --git a/app/src/utils/haptic/index.ts b/app/src/utils/haptic/index.ts index 71ba3597c..2a599c135 100644 --- a/app/src/utils/haptic/index.ts +++ b/app/src/utils/haptic/index.ts @@ -125,40 +125,11 @@ export const loadingScreenProgress = (shouldVibrate: boolean = true) => { return; } - // Function to trigger the haptic feedback - const triggerHaptic = () => { - if (Platform.OS === 'android') { - // Pattern: [delay, duration, delay, duration, ...] - // First heavy impact at 500ms - // Then three light impacts at 750ms intervals - triggerFeedback('custom', { - pattern: [ - 500, - 100, // Heavy impact - 750, - 50, // First light impact - 750, - 50, // Second light impact - 750, - 50, // Third light impact - ], - }); - } else { - setTimeout(() => { - triggerFeedback('impactHeavy'); - }, 750); - setTimeout(() => { - feedbackProgress(); - }, 750); - } - }; + triggerFeedback('impactHeavy'); - // Trigger immediately - triggerHaptic(); - - // Set up interval for continuous feedback - // Total pattern duration (2950ms) + 1 second pause (1000ms) = 3950ms - loadingScreenInterval = setInterval(triggerHaptic, 4000); + loadingScreenInterval = setInterval(() => { + triggerFeedback('impactHeavy'); + }, 1000); }; export const notificationError = () => triggerFeedback('notificationError'); diff --git a/app/src/utils/proving/loadingScreenStateText.ts b/app/src/utils/proving/loadingScreenStateText.ts index 6eb212932..391597aaa 100644 --- a/app/src/utils/proving/loadingScreenStateText.ts +++ b/app/src/utils/proving/loadingScreenStateText.ts @@ -2,60 +2,92 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha'; +import { + provingMachineCircuitType, + ProvingStateType, +} from '@selfxyz/mobile-sdk-alpha'; interface LoadingScreenText { actionText: string; + actionSubText: string; estimatedTime: string; + statusBarProgress: number; } export function getLoadingScreenText( state: ProvingStateType, signatureAlgorithm: string, curveOrExponent: string, - type: 'dsc' | 'register' = 'register', + type: provingMachineCircuitType, ): LoadingScreenText { + // Helper function to calculate progress with type offset + const getStatusBarProgress = (baseProgress: number): number => { + return baseProgress + (type === 'register' ? 3 : 0); + }; + switch (state) { // Initial states case 'idle': return { - actionText: 'Initializing...', - estimatedTime: '1 - 5 SECONDS', + actionText: 'Initializing', + actionSubText: 'Retrieving ID data from the keychain', + estimatedTime: '3 - 15 SECONDS', + statusBarProgress: getStatusBarProgress(0), + }; + + case 'parsing_id_document': + return { + actionText: 'Initializing', + actionSubText: 'Parsing ID data', + estimatedTime: '3 - 15 SECONDS', + statusBarProgress: getStatusBarProgress(0), }; // Data preparation states case 'fetching_data': return { - actionText: 'Reading current state of the registry', - estimatedTime: '5 - 10 SECONDS', + actionText: 'Reading registry', + actionSubText: 'Reading current state of the registry', + estimatedTime: '2 - 5 SECONDS', + statusBarProgress: getStatusBarProgress(1), }; + case 'validating_document': return { - actionText: 'Validating passport', - estimatedTime: '5 - 10 SECONDS', + actionText: 'Validating ID', + actionSubText: 'Validating ID data locally', + estimatedTime: '2 - 5 SECONDS', + statusBarProgress: getStatusBarProgress(1), }; // Connection states case 'init_tee_connexion': return { - actionText: 'Establishing secure connection', - estimatedTime: '5 - 10 SECONDS', + actionText: 'Securing connection', + actionSubText: 'Establishing secure connection to the registry', + estimatedTime: '2 - 5 SECONDS', + statusBarProgress: getStatusBarProgress(2), }; case 'listening_for_status': return { - actionText: 'Waiting for verification', - estimatedTime: '10 - 30 SECONDS', + actionText: 'Securing connection', + actionSubText: 'Establishing secure connection to the registry', + estimatedTime: '2 - 5 SECONDS', + statusBarProgress: getStatusBarProgress(2), }; - // Proving states case 'ready_to_prove': return { - actionText: 'Ready to verify', - estimatedTime: '1 - 3 SECONDS', + actionText: 'Securing connection', + actionSubText: 'Establishing secure connection to the registry', + estimatedTime: '2 - 5 SECONDS', + statusBarProgress: getStatusBarProgress(2), }; case 'proving': return { - actionText: 'Generating ZK proof', + actionText: 'Proving', + actionSubText: 'Generating the ZK proof', + statusBarProgress: getStatusBarProgress(2), estimatedTime: signatureAlgorithm && curveOrExponent ? getProvingTimeEstimate(signatureAlgorithm, curveOrExponent, type) @@ -63,15 +95,19 @@ export function getLoadingScreenText( }; case 'post_proving': return { - actionText: 'Finalizing verification', - estimatedTime: '5 - 10 SECONDS', + actionText: 'Verifying', + actionSubText: 'Waiting for verification of the ZK proof', + statusBarProgress: getStatusBarProgress(2), + estimatedTime: '1 - 2 SECONDS', }; // Success state case 'completed': return { actionText: 'Verified', - estimatedTime: '1 - 3 SECONDS', + actionSubText: 'Verification completed', + statusBarProgress: getStatusBarProgress(3), + estimatedTime: '0 - 1 SECONDS', }; // Error states @@ -79,30 +115,40 @@ export function getLoadingScreenText( case 'failure': return { actionText: 'Verification failed', - estimatedTime: '1 - 3 SECONDS', + actionSubText: 'Verification failed', + statusBarProgress: getStatusBarProgress(0), + estimatedTime: '0 - 1 SECONDS', }; // Special case states case 'passport_not_supported': return { actionText: 'Unsupported passport', + actionSubText: 'Unsupported passport', estimatedTime: '1 - 3 SECONDS', + statusBarProgress: getStatusBarProgress(0), }; case 'account_recovery_choice': return { actionText: 'Account recovery needed', + actionSubText: 'Account recovery needed', + statusBarProgress: getStatusBarProgress(0), estimatedTime: '1 - 3 SECONDS', }; case 'passport_data_not_found': return { actionText: 'Passport data not found', + actionSubText: 'Passport data not found', + statusBarProgress: getStatusBarProgress(0), estimatedTime: '1 - 3 SECONDS', }; default: return { - actionText: 'Verifying', - estimatedTime: '10 - 30 SECONDS', + actionText: '', + actionSubText: '', + statusBarProgress: getStatusBarProgress(0), + estimatedTime: 'SECONDS', }; } } @@ -110,7 +156,7 @@ export function getLoadingScreenText( export function getProvingTimeEstimate( signatureAlgorithm: string, curveOrExponent: string, - type: 'dsc' | 'register', + type: provingMachineCircuitType, ): string { if (!signatureAlgorithm || !curveOrExponent) return '30 - 90 SECONDS'; diff --git a/app/tests/src/navigation.test.ts b/app/tests/src/navigation.test.ts index 194bada06..db7e963da 100644 --- a/app/tests/src/navigation.test.ts +++ b/app/tests/src/navigation.test.ts @@ -21,6 +21,7 @@ describe('navigation', () => { 'DeferredLinkingInfo', 'DevFeatureFlags', 'DevHapticFeedback', + 'DevLoadingScreen', 'DevPrivateKey', 'DevSettings', 'Disclaimer', diff --git a/packages/mobile-sdk-alpha/src/constants/analytics.ts b/packages/mobile-sdk-alpha/src/constants/analytics.ts index 56307c97f..9c1e1d16b 100644 --- a/packages/mobile-sdk-alpha/src/constants/analytics.ts +++ b/packages/mobile-sdk-alpha/src/constants/analytics.ts @@ -152,6 +152,7 @@ export const ProofEvents = { FETCH_DATA_STARTED: 'Proof: Fetch Data Started', FETCH_DATA_SUCCESS: 'Proof: Fetch Data Succeeded', LOAD_SECRET_FAILED: 'Proof: Load Secret Failed', + PARSE_ID_DOCUMENT_STARTED: 'Proof: Parse ID Document Started', NOTIFICATION_PERMISSION_REQUESTED: 'Proof: Notification Permission Requested', PASSPORT_NULLIFIER_ONCHAIN: 'Proof: Passport Nullifier Onchain', PAYLOAD_ENCRYPTED: 'Proof: Payload Encrypted', diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts index ade6701cc..cfdd10fbf 100644 --- a/packages/mobile-sdk-alpha/src/index.ts +++ b/packages/mobile-sdk-alpha/src/index.ts @@ -67,6 +67,7 @@ export { type ProvingStateType } from './proving/provingMachine'; // Context and Client export { QRCodeScreen } from './components/screens/QRCodeScreen'; + export { SdkEvents } from './types/events'; // Components export { SelfClientContext, SelfClientProvider, useSelfClient } from './context'; @@ -99,6 +100,8 @@ export { mergeConfig } from './config/merge'; export { parseNFCResponse, scanNFC } from './nfc'; +export { provingMachineCircuitType } from './proving/provingMachine'; + export { reactNativeScannerAdapter } from './adapters/react-native/scanner'; export { scanQRProof } from './qr'; diff --git a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts index 0eeff062d..ed7b1465e 100644 --- a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts +++ b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts @@ -13,7 +13,12 @@ import { create } from 'zustand'; import type { DocumentCategory, PassportData } from '@selfxyz/common/types'; import type { EndpointType, SelfApp } from '@selfxyz/common/utils'; -import { getCircuitNameFromPassportData, getSolidityPackedUserContextData } from '@selfxyz/common/utils'; +import { + getCircuitNameFromPassportData, + getSKIPEM, + getSolidityPackedUserContextData, + initPassportDataParsing, +} from '@selfxyz/common/utils'; import { checkPCR0Mapping, validatePKIToken } from '@selfxyz/common/utils/attest'; import { generateTEEInputsDiscloseStateless, @@ -44,6 +49,7 @@ import { loadSelectedDocument, markCurrentDocumentAsRegistered, reStorePassportDataWithRightCSCA, + storePassportData, } from '../documents/utils'; import { fetchAllTreesAndCircuits, getCommitmentTree } from '../stores'; import { SdkEvents } from '../types/events'; @@ -220,6 +226,7 @@ export interface ProvingState { circuitType: 'dsc' | 'disclose' | 'register', userConfirmed?: boolean, ) => Promise; + parseIDDocument: (selfClient: SelfClient) => Promise; startFetchingData: (selfClient: SelfClient) => Promise; validatingDocument: (selfClient: SelfClient) => Promise; initTeeConnection: (selfClient: SelfClient) => Promise; @@ -257,11 +264,18 @@ const provingMachine = createMachine({ states: { idle: { on: { + PARSE_ID_DOCUMENT: 'parsing_id_document', FETCH_DATA: 'fetching_data', ERROR: 'error', PASSPORT_DATA_NOT_FOUND: 'passport_data_not_found', }, }, + parsing_id_document: { + on: { + PARSE_SUCCESS: 'fetching_data', + PARSE_ERROR: 'error', + }, + }, fetching_data: { on: { FETCH_SUCCESS: 'validating_document', @@ -329,6 +343,7 @@ export type ProvingStateType = | 'idle' | undefined // Data preparation states + | 'parsing_id_document' | 'fetching_data' | 'validating_document' // Connection states @@ -393,6 +408,9 @@ export const useProvingStore = create((set, get) => { }); set({ currentState: state.value as ProvingStateType }); + if (state.value === 'parsing_id_document') { + get().parseIDDocument(selfClient); + } if (state.value === 'fetching_data') { get().startFetchingData(selfClient); } @@ -860,6 +878,7 @@ export const useProvingStore = create((set, get) => { selfClient.trackEvent(PassportEvents.PASSPORT_DATA_NOT_FOUND, { stage: 'init', }); + console.error('No document found for proving in init'); actor!.send({ type: 'PASSPORT_DATA_NOT_FOUND' }); return; } @@ -879,8 +898,103 @@ export const useProvingStore = create((set, get) => { set({ passportData, secret, env }); set({ circuitType }); - actor.send({ type: 'FETCH_DATA' }); - selfClient.trackEvent(ProofEvents.FETCH_DATA_STARTED); + if (passportData.documentCategory === 'aadhaar') { + actor.send({ type: 'FETCH_DATA' }); + selfClient.trackEvent(ProofEvents.FETCH_DATA_STARTED); + } else { + // Skip parsing for disclosure if passport is already parsed + // Re-parsing would overwrite the alternative CSCA used during registration and is unnecessary + const isParsed = passportData.passportMetadata !== undefined; + if (circuitType === 'disclose' && isParsed) { + actor.send({ type: 'FETCH_DATA' }); + selfClient.trackEvent(ProofEvents.FETCH_DATA_STARTED); + } else { + actor.send({ type: 'PARSE_ID_DOCUMENT' }); + selfClient.trackEvent(ProofEvents.PARSE_ID_DOCUMENT_STARTED); + } + } + }, + + parseIDDocument: async (selfClient: SelfClient) => { + _checkActorInitialized(actor); + const startTime = Date.now(); + const context = createProofContext(selfClient, 'parseIDDocument'); + selfClient.logProofEvent('info', 'Parsing ID document started', context); + + try { + const { passportData, env } = get(); + if (!passportData) { + throw new Error('PassportData is not available'); + } + + selfClient.logProofEvent('info', 'ID document parsing process started', context); + + // Parse ID document logic (copied from parseIDDocument.ts but without try-catch wrapper) + const skiPem = await getSKIPEM(env === 'stg' ? 'staging' : 'production'); + const parsedPassportData = initPassportDataParsing(passportData as PassportData, skiPem); + if (!parsedPassportData) { + throw new Error('Failed to parse passport data'); + } + + const passportMetadata = parsedPassportData.passportMetadata!; + let dscObject; + try { + dscObject = { dsc: passportMetadata.dsc }; + } catch (error) { + console.error('Failed to parse dsc:', error); + dscObject = {}; + } + + selfClient.trackEvent(PassportEvents.PASSPORT_PARSED, { + success: true, + data_groups: passportMetadata.dataGroups, + dg1_size: passportMetadata.dg1Size, + dg1_hash_size: passportMetadata.dg1HashSize, + dg1_hash_function: passportMetadata.dg1HashFunction, + dg1_hash_offset: passportMetadata.dg1HashOffset, + dg_padding_bytes: passportMetadata.dgPaddingBytes, + e_content_size: passportMetadata.eContentSize, + e_content_hash_function: passportMetadata.eContentHashFunction, + e_content_hash_offset: passportMetadata.eContentHashOffset, + signed_attr_size: passportMetadata.signedAttrSize, + signed_attr_hash_function: passportMetadata.signedAttrHashFunction, + signature_algorithm: passportMetadata.signatureAlgorithm, + salt_length: passportMetadata.saltLength, + curve_or_exponent: passportMetadata.curveOrExponent, + signature_algorithm_bits: passportMetadata.signatureAlgorithmBits, + country_code: passportMetadata.countryCode, + csca_found: passportMetadata.cscaFound, + csca_hash_function: passportMetadata.cscaHashFunction, + csca_signature_algorithm: passportMetadata.cscaSignatureAlgorithm, + csca_salt_length: passportMetadata.cscaSaltLength, + csca_curve_or_exponent: passportMetadata.cscaCurveOrExponent, + csca_signature_algorithm_bits: passportMetadata.cscaSignatureAlgorithmBits, + dsc: dscObject, + dsc_aki: (passportData as PassportData).dsc_parsed?.authorityKeyIdentifier, + dsc_ski: (passportData as PassportData).dsc_parsed?.subjectKeyIdentifier, + }); + console.log('passport data parsed successfully, storing in keychain'); + await storePassportData(selfClient, parsedPassportData); + console.log('passport data stored in keychain'); + + set({ passportData: parsedPassportData }); + selfClient.logProofEvent('info', 'ID document parsing succeeded', context, { + duration_ms: Date.now() - startTime, + }); + actor!.send({ type: 'PARSE_SUCCESS' }); + } catch (error) { + selfClient.logProofEvent('error', 'ID document parsing failed', context, { + failure: 'PROOF_FAILED_PARSING', + error: error instanceof Error ? error.message : String(error), + duration_ms: Date.now() - startTime, + }); + console.error('Error parsing ID document:', error); + const errMsg = error instanceof Error ? error.message : String(error); + selfClient.trackEvent(PassportEvents.PASSPORT_PARSE_FAILED, { + error: errMsg, + }); + actor!.send({ type: 'PARSE_ERROR' }); + } }, startFetchingData: async (selfClient: SelfClient) => { @@ -1036,6 +1150,7 @@ export const useProvingStore = create((set, get) => { console.error('Error marking document as registered:', error); } })(); + set({ circuitType: 'register' }); // Update circuit type to 'register' to reflect full registration completion selfClient.trackEvent(ProofEvents.ALREADY_REGISTERED); selfClient.logProofEvent('info', 'Document already registered', context, {