From c7c9985d915c1b9cfb1270b675be04808eff7099 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Wed, 28 Jan 2026 20:10:50 -0800 Subject: [PATCH] SELF-1889: Initiate Sumsub during onboarding error flows (#1662) * integrate sumsub into error flows * formatting * fix test * format * clean up * udpate flows * agent feedback * updates * save wip updates * clean up design * updates * lint * agent feedback * formatting * fix --- app/metro.config.cjs | 3 + app/src/hooks/useErrorInjection.ts | 27 ++ app/src/hooks/useSumsubLauncher.ts | 127 +++++++ app/src/navigation/documents.ts | 12 + app/src/navigation/index.tsx | 11 + app/src/providers/selfClientProvider.tsx | 103 +++++- app/src/screens/app/LoadingScreen.tsx | 4 +- app/src/screens/dev/DevSettingsScreen.tsx | 162 +++++++++ .../scanning/DocumentCameraTroubleScreen.tsx | 40 ++- .../scanning/DocumentNFCScanScreen.tsx | 37 +- .../scanning/DocumentNFCTroubleScreen.tsx | 32 +- .../scanning/RegistrationFallbackScreen.tsx | 326 ++++++++++++++++++ app/src/stores/errorInjectionStore.ts | 103 ++++++ app/tests/src/navigation.test.tsx | 1 + packages/mobile-sdk-alpha/src/client.ts | 2 + .../mobile-sdk-alpha/src/config/defaults.ts | 2 +- packages/mobile-sdk-alpha/src/config/merge.ts | 5 +- .../src/flows/onboarding/read-mrz.ts | 29 +- packages/mobile-sdk-alpha/src/index.ts | 1 + packages/mobile-sdk-alpha/src/types/public.ts | 15 + .../mobile-sdk-alpha/tests/config.test.ts | 2 +- 21 files changed, 1006 insertions(+), 38 deletions(-) create mode 100644 app/src/hooks/useErrorInjection.ts create mode 100644 app/src/hooks/useSumsubLauncher.ts create mode 100644 app/src/screens/documents/scanning/RegistrationFallbackScreen.tsx create mode 100644 app/src/stores/errorInjectionStore.ts diff --git a/app/metro.config.cjs b/app/metro.config.cjs index 24797c027..f86597c98 100644 --- a/app/metro.config.cjs +++ b/app/metro.config.cjs @@ -72,6 +72,9 @@ const config = { new RegExp( 'packages/mobile-sdk-alpha/node_modules/react-native-svg(/|$)', ), + new RegExp( + 'packages/mobile-sdk-alpha/node_modules/react-native-webview(/|$)', + ), new RegExp('packages/mobile-sdk-demo/node_modules/react(/|$)'), new RegExp('packages/mobile-sdk-demo/node_modules/react-dom(/|$)'), new RegExp('packages/mobile-sdk-demo/node_modules/react-native(/|$)'), diff --git a/app/src/hooks/useErrorInjection.ts b/app/src/hooks/useErrorInjection.ts new file mode 100644 index 000000000..763e1954a --- /dev/null +++ b/app/src/hooks/useErrorInjection.ts @@ -0,0 +1,27 @@ +// 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 { useCallback } from 'react'; + +import type { InjectedErrorType } from '@/stores/errorInjectionStore'; +import { useErrorInjectionStore } from '@/stores/errorInjectionStore'; +import { IS_DEV_MODE } from '@/utils/devUtils'; + +/** + * Hook for checking if a specific error should be injected + * Only active in dev mode + */ +export function useErrorInjection() { + const injectedErrors = useErrorInjectionStore(state => state.injectedErrors); + + const shouldInjectError = useCallback( + (errorType: InjectedErrorType): boolean => { + if (!IS_DEV_MODE) return false; + return injectedErrors.includes(errorType); + }, + [injectedErrors], + ); + + return { shouldInjectError }; +} diff --git a/app/src/hooks/useSumsubLauncher.ts b/app/src/hooks/useSumsubLauncher.ts new file mode 100644 index 000000000..c5cad2260 --- /dev/null +++ b/app/src/hooks/useSumsubLauncher.ts @@ -0,0 +1,127 @@ +// 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 { useCallback, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { sanitizeErrorMessage } from '@selfxyz/mobile-sdk-alpha'; + +import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub'; +import type { SumsubResult } from '@/integrations/sumsub/types'; +import type { RootStackParamList } from '@/navigation'; + +export type FallbackErrorSource = + | 'mrz_scan_failed' + | 'nfc_scan_failed' + | 'sumsub_initialization' + | 'sumsub_verification'; + +export interface UseSumsubLauncherOptions { + /** + * Country code for the user's document + */ + countryCode: string; + /** + * Error source to track where the Sumsub launch was initiated from + */ + errorSource: FallbackErrorSource; + /** + * Optional callback to handle successful verification + */ + onSuccess?: (result: SumsubResult) => void | Promise; + /** + * Optional callback to handle user cancellation + */ + onCancel?: () => void | Promise; + /** + * Optional callback to handle verification failure + */ + onError?: (error: unknown, result?: SumsubResult) => void | Promise; +} + +/** + * Custom hook for launching Sumsub verification with consistent error handling. + * + * Abstracts the common pattern of: + * 1. Fetching access token + * 2. Launching Sumsub SDK + * 3. Handling errors by navigating to fallback screen + * 4. Managing loading state + * + * @example + * ```tsx + * const { launchSumsubVerification, isLoading } = useSumsubLauncher({ + * countryCode: 'US', + * errorSource: 'nfc_scan_failed', + * }); + * + * + * ``` + */ +export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => { + const { countryCode, errorSource, onSuccess, onCancel, onError } = options; + const navigation = + useNavigation>(); + const [isLoading, setIsLoading] = useState(false); + + const launchSumsubVerification = useCallback(async () => { + setIsLoading(true); + try { + const accessToken = await fetchAccessToken(); + const result = await launchSumsub({ accessToken: accessToken.token }); + + // Handle user cancellation + if (!result.success && result.status === 'Interrupted') { + await onCancel?.(); + return; + } + + // Handle verification failure + if (!result.success) { + const error = result.errorMsg || result.errorType || 'Unknown error'; + const safeError = sanitizeErrorMessage(error); + console.error('Sumsub verification failed:', safeError); + + // Call custom error handler if provided, otherwise navigate to fallback screen + if (onError) { + await onError(safeError, result); + } else { + navigation.navigate('RegistrationFallback', { + errorSource, + countryCode, + }); + } + return; + } + + // Handle success + await onSuccess?.(result); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const safeError = sanitizeErrorMessage(errorMessage); + console.error('Error launching alternative verification:', safeError); + + // Call custom error handler if provided, otherwise navigate to fallback screen + if (onError) { + await onError(safeError); + } else { + navigation.navigate('RegistrationFallback', { + errorSource, + countryCode, + }); + } + } finally { + setIsLoading(false); + } + }, [navigation, countryCode, errorSource, onSuccess, onCancel, onError]); + + return { + launchSumsubVerification, + isLoading, + }; +}; diff --git a/app/src/navigation/documents.ts b/app/src/navigation/documents.ts index ed9faeab4..a80aaeecc 100644 --- a/app/src/navigation/documents.ts +++ b/app/src/navigation/documents.ts @@ -19,6 +19,7 @@ import DocumentCameraTroubleScreen from '@/screens/documents/scanning/DocumentCa import DocumentNFCMethodSelectionScreen from '@/screens/documents/scanning/DocumentNFCMethodSelectionScreen'; import DocumentNFCScanScreen from '@/screens/documents/scanning/DocumentNFCScanScreen'; import DocumentNFCTroubleScreen from '@/screens/documents/scanning/DocumentNFCTroubleScreen'; +import RegistrationFallbackScreen from '@/screens/documents/scanning/RegistrationFallbackScreen'; import ConfirmBelongingScreen from '@/screens/documents/selection/ConfirmBelongingScreen'; import CountryPickerScreen from '@/screens/documents/selection/CountryPickerScreen'; import DocumentOnboardingScreen from '@/screens/documents/selection/DocumentOnboardingScreen'; @@ -155,6 +156,17 @@ const documentsScreens = { errorType: 'general', }, }, + RegistrationFallback: { + screen: RegistrationFallbackScreen, + options: { + title: 'REGISTRATION', + headerShown: false, + } as NativeStackNavigationOptions, + initialParams: { + errorSource: 'sumsub_initialization', + countryCode: '', + }, + }, }; export default documentsScreens; diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index 67273f933..65d68e114 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -79,6 +79,7 @@ export type RootStackParamList = Omit< | 'Home' | 'IDPicker' | 'IdDetails' + | 'RegistrationFallback' | 'Loading' | 'Modal' | 'MockDataDeepLink' @@ -127,6 +128,16 @@ export type RootStackParamList = Omit< errorType: string; }; + // Registration Fallback screens + RegistrationFallback: { + errorSource: + | 'mrz_scan_failed' + | 'nfc_scan_failed' + | 'sumsub_initialization' + | 'sumsub_verification'; + countryCode: string; + }; + // Account/Recovery screens AccountRecovery: | { diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index 94ca68aff..106133216 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -13,9 +13,11 @@ import { type LogLevel, type NFCScanContext, reactNativeScannerAdapter, + sanitizeErrorMessage, SdkEvents, SelfClientProvider as SDKSelfClientProvider, type TrackEventParams, + useMRZStore, webNFCScannerShim, type WsConn, } from '@selfxyz/mobile-sdk-alpha'; @@ -33,7 +35,12 @@ import { setPassportKeychainErrorCallback, } from '@/providers/passportDataProvider'; import { trackEvent, trackNfcEvent } from '@/services/analytics'; +import { + type InjectedErrorType, + useErrorInjectionStore, +} from '@/stores/errorInjectionStore'; import { useSettingStore } from '@/stores/settingStore'; +import { IS_DEV_MODE } from '@/utils/devUtils'; import { registerModalCallbacks, unregisterModalCallbacks, @@ -68,7 +75,20 @@ function navigateIfReady( } export const SelfClientProvider = ({ children }: PropsWithChildren) => { - const config = useMemo(() => ({}), []); + const config = useMemo( + () => ({ + devConfig: IS_DEV_MODE + ? { + shouldTrigger: (errorType: string) => { + return useErrorInjectionStore + .getState() + .shouldTrigger(errorType as InjectedErrorType); + }, + } + : undefined, + }), + [], + ); const adapters: Adapters = useMemo( () => ({ scanner: @@ -167,6 +187,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { const appListeners = useMemo(() => { const { map, addListener } = createListenersMap(); + // Track current countryCode for error navigation + let currentCountryCode = ''; + addListener(SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND, () => { if (navigationRef.isReady()) { navigationRef.navigate('DocumentDataNotFound'); @@ -261,7 +284,10 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { }); addListener(SdkEvents.DOCUMENT_MRZ_READ_FAILURE, () => { - navigateIfReady('DocumentCameraTrouble'); + navigateIfReady('RegistrationFallback', { + errorSource: 'mrz_scan_failed', + countryCode: currentCountryCode, + }); }); addListener(SdkEvents.PROVING_AADHAAR_UPLOAD_SUCCESS, () => { @@ -280,6 +306,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { countryCode: string; documentTypes: string[]; }) => { + currentCountryCode = countryCode; + // Store country code early so it's available for Sumsub fallback flows + useMRZStore.getState().update({ countryCode }); navigateIfReady('IDPicker', { countryCode, documentTypes }); }, ); @@ -300,14 +329,68 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { } break; case 'kyc': - fetchAccessToken() - .then(accessToken => { - launchSumsub({ accessToken: accessToken.token }); - }) - // TODO: show sumsub error screen - .catch(error => { - console.error('Error launching Sumsub:', error); - }); + (async () => { + try { + // Dev-only: Check for injected initialization error + if ( + useErrorInjectionStore + .getState() + .shouldTrigger('sumsub_initialization') + ) { + console.log('[DEV] Injecting Sumsub initialization error'); + throw new Error( + 'Injected Sumsub initialization error for testing', + ); + } + + const accessToken = await fetchAccessToken(); + const result = await launchSumsub({ + accessToken: accessToken.token, + }); + + // User cancelled - return silently + if (!result.success && result.status === 'Interrupted') { + return; + } + + // Dev-only: Check for injected verification error + const shouldInjectVerificationError = useErrorInjectionStore + .getState() + .shouldTrigger('sumsub_verification'); + + // Actual error from provider + if (!result.success || shouldInjectVerificationError) { + if (shouldInjectVerificationError) { + console.log('[DEV] Injecting Sumsub verification error'); + } else { + const safeError = sanitizeErrorMessage( + result.errorMsg || result.errorType || 'unknown_error', + ); + console.error('KYC provider failed:', safeError); + } + // Guard navigation call after async operations + if (navigationRef.isReady()) { + navigationRef.navigate('RegistrationFallback', { + errorSource: 'sumsub_verification', + countryCode, + }); + } + } + // success case: provider handles its own success UI + } catch (error) { + const safeInitError = sanitizeErrorMessage( + error instanceof Error ? error.message : String(error), + ); + console.error('Error in KYC flow:', safeInitError); + // Guard navigation call after async operations + if (navigationRef.isReady()) { + navigationRef.navigate('RegistrationFallback', { + errorSource: 'sumsub_initialization', + countryCode, + }); + } + } + })(); break; default: if (countryCode) { diff --git a/app/src/screens/app/LoadingScreen.tsx b/app/src/screens/app/LoadingScreen.tsx index 9d9e9deae..1920f760d 100644 --- a/app/src/screens/app/LoadingScreen.tsx +++ b/app/src/screens/app/LoadingScreen.tsx @@ -107,8 +107,8 @@ const LoadingScreen: React.FC = ({ route }) => { } else { await init(selfClient, 'dsc', true); } - } catch { - console.error('Error loading selected document:'); + } catch (error) { + console.error('Error loading selected document:', error); await init(selfClient, 'dsc', true); } finally { setIsInitializing(false); diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index ea4e58bc1..22f80fff9 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -44,6 +44,12 @@ import { subscribeToTopics, unsubscribeFromTopics, } from '@/services/notifications/notificationService'; +import type { InjectedErrorType } from '@/stores/errorInjectionStore'; +import { + ERROR_GROUPS, + ERROR_LABELS, + useErrorInjectionStore, +} from '@/stores/errorInjectionStore'; import { usePointEventStore } from '@/stores/pointEventStore'; import { useSettingStore } from '@/stores/settingStore'; import { IS_DEV_MODE } from '@/utils/devUtils'; @@ -390,6 +396,152 @@ const LogLevelSelector = ({ ); }; +const ErrorInjectionSelector = () => { + const injectedErrors = useErrorInjectionStore(state => state.injectedErrors); + const setInjectedErrors = useErrorInjectionStore( + state => state.setInjectedErrors, + ); + const clearAllErrors = useErrorInjectionStore(state => state.clearAllErrors); + const [open, setOpen] = useState(false); + + // Single error selection - replace instead of toggle + const selectError = (errorType: InjectedErrorType) => { + // If clicking the same error, clear it; otherwise set the new one + if (injectedErrors.length === 1 && injectedErrors[0] === errorType) { + clearAllErrors(); + } else { + setInjectedErrors([errorType]); + } + // Close the sheet after selection + setOpen(false); + }; + + const currentError = injectedErrors.length > 0 ? injectedErrors[0] : null; + const currentErrorLabel = currentError ? ERROR_LABELS[currentError] : null; + + return ( + + + + {currentError && ( + + )} + + + + + + + + Onboarding Error Testing + + + + + {Object.entries(ERROR_GROUPS).map(([groupName, errors]) => ( + + + {groupName} + + {errors.map((errorType: InjectedErrorType) => ( + selectError(errorType)} + > + + + {ERROR_LABELS[errorType]} + + {currentError === errorType && ( + + )} + + + ))} + + ))} + + + + + + ); +}; + const DevSettingsScreen: React.FC = ({}) => { const { clearDocumentCatalogForMigrationTesting } = usePassport(); const clearPointEvents = usePointEventStore(state => state.clearEvents); @@ -779,6 +931,16 @@ const DevSettingsScreen: React.FC = ({}) => { /> + {IS_DEV_MODE && ( + } + title="Onboarding Error Testing" + description="Test onboarding error flows" + > + + + )} + {Platform.OS === 'android' && ( } diff --git a/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx b/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx index e9d0a2c10..c99d5e242 100644 --- a/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx @@ -3,9 +3,11 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import React, { useEffect } from 'react'; +import { YStack } from 'tamagui'; -import { Caption } from '@selfxyz/mobile-sdk-alpha/components'; -import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; +import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components'; +import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import Activity from '@/assets/icons/activity.svg'; import PassportCameraBulb from '@/assets/icons/passport_camera_bulb.svg'; @@ -15,6 +17,7 @@ import Star from '@/assets/icons/star.svg'; import type { TipProps } from '@/components/Tips'; import Tips from '@/components/Tips'; import useHapticNavigation from '@/hooks/useHapticNavigation'; +import { useSumsubLauncher } from '@/hooks/useSumsubLauncher'; import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout'; import { flush as flushAnalytics } from '@/services/analytics'; @@ -48,6 +51,13 @@ const tips: TipProps[] = [ const DocumentCameraTroubleScreen: React.FC = () => { const go = useHapticNavigation('DocumentCamera', { action: 'cancel' }); + const selfClient = useSelfClient(); + const { useMRZStore } = selfClient; + const { countryCode } = useMRZStore(); + const { launchSumsubVerification, isLoading } = useSumsubLauncher({ + countryCode, + errorSource: 'sumsub_initialization', + }); // error screen, flush analytics useEffect(() => { @@ -64,10 +74,28 @@ const DocumentCameraTroubleScreen: React.FC = () => { } footer={ - - Following these steps should help your phone's camera capture the ID - page quickly and clearly! - + + + Following these steps should help your phone's camera capture the ID + page quickly and clearly! + + + + Or try an alternative verification method: + + + + {isLoading ? 'Loading...' : 'Try Alternative Verification'} + + } > diff --git a/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx b/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx index 72f1845aa..4f95a70d2 100644 --- a/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx @@ -54,6 +54,7 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; import passportVerifyAnimation from '@/assets/animations/passport_verify.json'; import NFC_IMAGE from '@/assets/images/nfc.png'; import { logNFCEvent } from '@/config/sentry'; +import { useErrorInjection } from '@/hooks/useErrorInjection'; import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import { @@ -106,8 +107,9 @@ const DocumentNFCScanScreen: React.FC = () => { const navigation = useNavigation>(); const route = useRoute(); - const { showModal } = useFeedback(); + useFeedback(); useFeedbackAutoHide(); + const { shouldInjectError } = useErrorInjection(); const { passportNumber, dateOfBirth, @@ -189,18 +191,12 @@ const DocumentNFCScanScreen: React.FC = () => { }, { message: sanitizeErrorMessage(message) }, ); - showModal({ - titleText: 'NFC Scan Error', - bodyText: message, - buttonText: SUPPORT_FORM_BUTTON_TEXT, - secondaryButtonText: 'Help', - preventDismiss: false, - onButtonPress: openSupportForm, - onSecondaryButtonPress: goToNFCTrouble, - onModalDismiss: () => {}, + navigation.navigate('RegistrationFallback', { + errorSource: 'nfc_scan_failed', + countryCode, }); }, - [baseContext, showModal, goToNFCTrouble], + [baseContext, navigation, countryCode], ); const checkNfcSupport = useCallback(async () => { @@ -324,6 +320,18 @@ const DocumentNFCScanScreen: React.FC = () => { }, 30000); try { + // Dev-only: Check for injected timeout error + if (shouldInjectError('nfc_timeout')) { + console.log('[DEV] Injecting NFC timeout error'); + throw new Error('Injected timeout error for testing'); + } + + // Dev-only: Check for injected module unavailable error + if (shouldInjectError('nfc_module_unavailable')) { + console.log('[DEV] Injecting NFC module unavailable error'); + throw new Error('NFC scanning is currently unavailable'); + } + const { canNumber, useCan, @@ -376,6 +384,12 @@ const DocumentNFCScanScreen: React.FC = () => { ); let passportData: PassportData | null = null; try { + // Dev-only: Check for injected parse failure error + if (shouldInjectError('nfc_parse_failure')) { + console.log('[DEV] Injecting NFC parse failure error'); + throw new Error('Failed to parse NFC response'); + } + passportData = parseScanResponse(scanResponse); } catch (e: unknown) { console.error('Parsing NFC Response Unsuccessful'); @@ -452,6 +466,7 @@ const DocumentNFCScanScreen: React.FC = () => { navigation, openErrorModal, trackEvent, + shouldInjectError, ]); const navigateToHome = useHapticNavigation('Home', { diff --git a/app/src/screens/documents/scanning/DocumentNFCTroubleScreen.tsx b/app/src/screens/documents/scanning/DocumentNFCTroubleScreen.tsx index ab7d76326..0af2728d0 100644 --- a/app/src/screens/documents/scanning/DocumentNFCTroubleScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentNFCTroubleScreen.tsx @@ -7,13 +7,15 @@ import { View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { YStack } from 'tamagui'; +import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components'; -import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import type { TipProps } from '@/components/Tips'; import Tips from '@/components/Tips'; import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide'; import useHapticNavigation from '@/hooks/useHapticNavigation'; +import { useSumsubLauncher } from '@/hooks/useSumsubLauncher'; import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout'; import { flushAllAnalytics } from '@/services/analytics'; import { openSupportForm, SUPPORT_FORM_BUTTON_TEXT } from '@/services/support'; @@ -50,6 +52,13 @@ const DocumentNFCTroubleScreen: React.FC = () => { const goToNFCMethodSelection = useHapticNavigation( 'DocumentNFCMethodSelection', ); + const selfClient = useSelfClient(); + const { useMRZStore } = selfClient; + const { countryCode } = useMRZStore(); + const { launchSumsubVerification, isLoading } = useSumsubLauncher({ + countryCode, + errorSource: 'sumsub_initialization', + }); useFeedbackAutoHide(); // error screen, flush analytics @@ -71,9 +80,24 @@ const DocumentNFCTroubleScreen: React.FC = () => { secondaryButtonText="Open NFC Options" onSecondaryButtonPress={goToNFCMethodSelection} footer={ - - {SUPPORT_FORM_BUTTON_TEXT} - + + + {SUPPORT_FORM_BUTTON_TEXT} + + + + {isLoading ? 'Loading...' : 'Try Alternative Verification'} + + } > , + string +>; + +const getHeaderTitle = (errorSource: FallbackErrorSource): string => { + switch (errorSource) { + case 'mrz_scan_failed': + return 'MRZ SCAN'; + case 'nfc_scan_failed': + return 'NFC SCAN'; + default: + return 'REGISTRATION'; + } +}; + +const getCurrentStep = (errorSource: FallbackErrorSource): number => { + switch (errorSource) { + case 'mrz_scan_failed': + return 1; // Step 1: MRZ scanning + case 'nfc_scan_failed': + return 2; // Step 2: NFC reading + case 'sumsub_initialization': + case 'sumsub_verification': + return 3; // Step 3: Proving/verification + default: + return 1; + } +}; + +const getRetryButtonText = (errorSource: FallbackErrorSource): string => { + switch (errorSource) { + case 'mrz_scan_failed': + return 'Try scanning again'; + case 'nfc_scan_failed': + return 'Try reading again'; + default: + return 'Try again'; + } +}; + +const getErrorMessages = ( + errorSource: FallbackErrorSource, +): { title: string; description: string; canRetryOriginal: boolean } => { + switch (errorSource) { + case 'mrz_scan_failed': + return { + title: 'There was a problem scanning your document', + description: 'Make sure the document is clearly visible and try again', + canRetryOriginal: true, + }; + case 'nfc_scan_failed': + return { + title: 'There was a problem reading the chip', + description: 'Make sure NFC is enabled and try again', + canRetryOriginal: true, + }; + case 'sumsub_initialization': + return { + title: 'Connection Error', + description: + 'Unable to connect to verification service. Please check your internet connection and try again.', + canRetryOriginal: false, + }; + case 'sumsub_verification': + return { + title: 'Verification Error', + description: + 'Something went wrong during the verification process. Please try again.', + canRetryOriginal: false, + }; + } +}; + +const RegistrationFallbackScreen: React.FC = () => { + const insets = useSafeAreaInsets(); + const paddingBottom = useSafeBottomPadding(extraYPadding + 35); + const navigation = + useNavigation>(); + const route = useRoute(); + const selfClient = useSelfClient(); + const { trackEvent, useMRZStore } = selfClient; + const storeCountryCode = useMRZStore(state => state.countryCode); + + const errorSource = route.params?.errorSource || 'sumsub_initialization'; + // Use country code from route params, or fall back to MRZ store + const countryCode = route.params?.countryCode || storeCountryCode || ''; + + const headerTitle = getHeaderTitle(errorSource); + const retryButtonText = getRetryButtonText(errorSource); + const currentStep = getCurrentStep(errorSource); + const { title, description, canRetryOriginal } = + getErrorMessages(errorSource); + + const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher( + { + countryCode, + errorSource, + onCancel: () => { + navigation.goBack(); + }, + onError: (_error, _result) => { + // Stay on this screen - user can try again + // Error is already logged in the hook + }, + onSuccess: () => { + // Success - provider handles its own success UI + // The screen will be navigated away by the provider's flow + }, + }, + ); + + const handleClose = useCallback(() => { + buttonTap(); + navigation.goBack(); + }, [navigation]); + + const handleTryAlternative = useCallback(async () => { + trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', { errorSource }); + await launchSumsubVerification(); + }, [errorSource, launchSumsubVerification, trackEvent]); + + const handleRetryOriginal = useCallback(() => { + trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', { errorSource }); + + // Navigate back to the appropriate screen based on error source + if (errorSource === 'mrz_scan_failed') { + navigation.navigate('DocumentCamera'); + } else if (errorSource === 'nfc_scan_failed') { + navigation.navigate('DocumentNFCScan', {}); + } else if (errorSource === 'sumsub_initialization') { + // Go back to ID Picker + navigation.goBack(); + } + // TODO: Handle 'sumsub_verification' case - currently falls through without action + // which could leave users stuck when tapping "Try again" after Sumsub verification failure. + // Consider: calling launchSumsubVerification() or navigating to appropriate retry screen. + // Need to determine the correct retry behavior for failed Sumsub verifications. + }, [errorSource, navigation, trackEvent]); + + return ( + + {/* Header */} + + + + + + {headerTitle} + + + + + + {/* Progress Bar */} + + + {[1, 2, 3, 4].map(step => ( + + ))} + + + + + {/* Warning Icon */} + + + + + + + {/* Error Message */} + + + {title} + + + {description} + + + + {/* Top Button - Retry */} + {canRetryOriginal && ( + + + {retryButtonText} + + + )} + + {/* Bottom Section with Grey Line Separator */} + + + {isRetrying ? 'Loading...' : 'Try a different method'} + + + {/* Footer Text */} + + Registering with alternative methods may take longer to verify your + document. + + + + ); +}; + +export default RegistrationFallbackScreen; diff --git a/app/src/stores/errorInjectionStore.ts b/app/src/stores/errorInjectionStore.ts new file mode 100644 index 000000000..6090a6617 --- /dev/null +++ b/app/src/stores/errorInjectionStore.ts @@ -0,0 +1,103 @@ +// 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 { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { IS_DEV_MODE } from '@/utils/devUtils'; + +export type InjectedErrorType = + | 'mrz_invalid_format' + | 'mrz_unknown_error' + | 'nfc_timeout' + | 'nfc_module_unavailable' + | 'nfc_parse_failure' + | 'api_network_error' + | 'api_timeout' + | 'sumsub_initialization' + | 'sumsub_verification'; + +export const ERROR_GROUPS = { + MRZ: ['mrz_invalid_format', 'mrz_unknown_error'] as InjectedErrorType[], + NFC: [ + 'nfc_timeout', + 'nfc_module_unavailable', + 'nfc_parse_failure', + ] as InjectedErrorType[], + API: ['api_network_error', 'api_timeout'] as InjectedErrorType[], + Sumsub: [ + 'sumsub_initialization', + 'sumsub_verification', + ] as InjectedErrorType[], +}; + +export const ERROR_LABELS: Record = { + mrz_invalid_format: 'MRZ: Invalid format', + mrz_unknown_error: 'MRZ: Unknown error', + nfc_timeout: 'NFC: Timeout', + nfc_module_unavailable: 'NFC: Module unavailable', + nfc_parse_failure: 'NFC: Parse failure', + api_network_error: 'API: Network error', + api_timeout: 'API: Timeout', + sumsub_initialization: 'Sumsub: Initialization', + sumsub_verification: 'Sumsub: Verification', +}; + +interface ErrorInjectionState { + injectedErrors: InjectedErrorType[]; + // Actions + setInjectedErrors: (errors: InjectedErrorType[]) => void; + toggleError: (error: InjectedErrorType) => void; + clearError: (error: InjectedErrorType) => void; + clearAllErrors: () => void; + shouldTrigger: (error: InjectedErrorType) => boolean; +} + +export const useErrorInjectionStore = create()( + persist( + (set, get) => ({ + injectedErrors: [], + + setInjectedErrors: (errors: InjectedErrorType[]) => { + if (!IS_DEV_MODE) return; + set({ injectedErrors: errors }); + }, + + toggleError: (error: InjectedErrorType) => { + if (!IS_DEV_MODE) return; + set(state => { + const hasError = state.injectedErrors.includes(error); + return { + injectedErrors: hasError + ? state.injectedErrors.filter(e => e !== error) + : [...state.injectedErrors, error], + }; + }); + }, + + clearError: (error: InjectedErrorType) => { + if (!IS_DEV_MODE) return; + set(state => ({ + injectedErrors: state.injectedErrors.filter(e => e !== error), + })); + }, + + clearAllErrors: () => { + if (!IS_DEV_MODE) return; + set({ injectedErrors: [] }); + }, + + shouldTrigger: (error: InjectedErrorType) => { + if (!IS_DEV_MODE) return false; + const state = get(); + return state.injectedErrors.includes(error); + }, + }), + { + name: 'error-injection-storage', + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 6723984c9..4c9a7ef26 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -101,6 +101,7 @@ describe('navigation', () => { 'QRCodeViewFinder', 'RecoverWithPhrase', 'Referral', + 'RegistrationFallback', 'SaveRecoveryPhrase', 'Settings', 'ShowRecoveryPhrase', diff --git a/packages/mobile-sdk-alpha/src/client.ts b/packages/mobile-sdk-alpha/src/client.ts index 5903a7b8f..7c1dd5251 100644 --- a/packages/mobile-sdk-alpha/src/client.ts +++ b/packages/mobile-sdk-alpha/src/client.ts @@ -224,5 +224,7 @@ export function createSelfClient({ useSelfAppStore, useProtocolStore, useMRZStore, + // Expose config for internal SDK use + config: cfg, }; } diff --git a/packages/mobile-sdk-alpha/src/config/defaults.ts b/packages/mobile-sdk-alpha/src/config/defaults.ts index cb45caa43..2ceb76ec0 100644 --- a/packages/mobile-sdk-alpha/src/config/defaults.ts +++ b/packages/mobile-sdk-alpha/src/config/defaults.ts @@ -4,7 +4,7 @@ import type { Config } from '../types/public'; -export const defaultConfig: Required = { +export const defaultConfig: Omit, 'devConfig'> & Pick = { timeouts: { scanMs: 60000 }, // in future this can be used to enable/disable experimental features features: {}, diff --git a/packages/mobile-sdk-alpha/src/config/merge.ts b/packages/mobile-sdk-alpha/src/config/merge.ts index 8602110d8..e635252e2 100644 --- a/packages/mobile-sdk-alpha/src/config/merge.ts +++ b/packages/mobile-sdk-alpha/src/config/merge.ts @@ -4,11 +4,14 @@ import type { Config } from '../types/public'; -export function mergeConfig(base: Required, override: Config): Required { +type BaseConfig = Omit, 'devConfig'> & Pick; + +export function mergeConfig(base: BaseConfig, override: Config): BaseConfig { return { ...base, ...override, timeouts: { ...base.timeouts, ...(override.timeouts ?? {}) }, features: { ...base.features, ...(override.features ?? {}) }, + devConfig: override.devConfig ?? base.devConfig, }; } diff --git a/packages/mobile-sdk-alpha/src/flows/onboarding/read-mrz.ts b/packages/mobile-sdk-alpha/src/flows/onboarding/read-mrz.ts index 069675ed9..a90a60c00 100644 --- a/packages/mobile-sdk-alpha/src/flows/onboarding/read-mrz.ts +++ b/packages/mobile-sdk-alpha/src/flows/onboarding/read-mrz.ts @@ -12,6 +12,9 @@ import { checkScannedInfo, formatDateToYYMMDD } from '../../processing/mrz'; import { SdkEvents } from '../../types/events'; import type { MRZInfo } from '../../types/public'; +// Dev-only error injection - uses injected devConfig from SDK context +// No cross-package requires needed + export type { MRZScannerViewProps } from '../../components/MRZScannerView'; export { MRZScannerView } from '../../components/MRZScannerView'; @@ -28,12 +31,25 @@ const calculateScanDurationSeconds = (scanStartTimeRef: RefObject) => { export function useReadMRZ(scanStartTimeRef: RefObject) { const selfClient = useSelfClient(); + const shouldTrigger = selfClient.config?.devConfig?.shouldTrigger; return { onPassportRead: useCallback( (error: Error | null, result?: MRZInfo) => { const scanDurationSeconds = calculateScanDurationSeconds(scanStartTimeRef); + // Dev-only: Check for injected unknown error + if (shouldTrigger?.('mrz_unknown_error')) { + console.log('[DEV] Injecting MRZ unknown error'); + selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { + reason: 'unknown_error', + error: 'Injected error for testing', + duration_seconds: parseFloat(scanDurationSeconds), + }); + selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_FAILURE); + return; + } + if (error) { console.error(error); @@ -63,7 +79,16 @@ export function useReadMRZ(scanStartTimeRef: RefObject) { const formattedDateOfBirth = Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfBirth) : dateOfBirth; const formattedDateOfExpiry = Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfExpiry) : dateOfExpiry; - if (!checkScannedInfo(documentNumber, formattedDateOfBirth, formattedDateOfExpiry)) { + // Dev-only: Check for injected invalid format error + const shouldInjectInvalidFormat = shouldTrigger?.('mrz_invalid_format') || false; + + if ( + shouldInjectInvalidFormat || + !checkScannedInfo(documentNumber, formattedDateOfBirth, formattedDateOfExpiry) + ) { + if (shouldInjectInvalidFormat) { + console.log('[DEV] Injecting MRZ invalid format error'); + } selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { reason: 'invalid_format', passportNumberLength: documentNumber.length, @@ -90,7 +115,7 @@ export function useReadMRZ(scanStartTimeRef: RefObject) { selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_SUCCESS); }, - [selfClient], + [selfClient, shouldTrigger], ), }; } diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts index 7ebeae490..ac8357c4e 100644 --- a/packages/mobile-sdk-alpha/src/index.ts +++ b/packages/mobile-sdk-alpha/src/index.ts @@ -138,5 +138,6 @@ export { parseNFCResponse, scanNFC } from './nfc'; export { reactNativeScannerAdapter } from './adapters/react-native/nfc-scanner'; export { sanitizeErrorMessage } from './utils/utils'; export { useCountries } from './documents/useCountries'; +export { useMRZStore } from './stores/mrzStore'; export { webNFCScannerShim } from './adapters/web/shims'; diff --git a/packages/mobile-sdk-alpha/src/types/public.ts b/packages/mobile-sdk-alpha/src/types/public.ts index df74c79a1..7b6483cf0 100644 --- a/packages/mobile-sdk-alpha/src/types/public.ts +++ b/packages/mobile-sdk-alpha/src/types/public.ts @@ -35,6 +35,18 @@ export interface Config { * treated as `false` and the SDK will continue using legacy flows. */ features?: Record; + /** + * Optional dev-mode configuration for error injection and testing. Should + * only be provided in development builds. Production builds should omit this. + */ + devConfig?: { + /** + * Callback to check if a specific error type should be injected for testing. + * @param errorType - The type of error to check (e.g., 'mrz_unknown_error') + * @returns true if the error should be injected, false otherwise + */ + shouldTrigger?: (errorType: string) => boolean; + }; } /** @@ -375,6 +387,9 @@ export interface SelfClient { useProtocolStore: ReturnType>; /** Zustand store hook mirroring {@link MRZState}. */ useMRZStore: ReturnType>; + + /** The merged configuration object passed to createSelfClient. */ + config: Config; } /** Function returned by {@link SelfClient.on} to detach a listener. */ diff --git a/packages/mobile-sdk-alpha/tests/config.test.ts b/packages/mobile-sdk-alpha/tests/config.test.ts index 119df55e1..91836042f 100644 --- a/packages/mobile-sdk-alpha/tests/config.test.ts +++ b/packages/mobile-sdk-alpha/tests/config.test.ts @@ -8,7 +8,7 @@ import { mergeConfig } from '../src/config/merge'; import type { Config } from '../src/types/public'; describe('mergeConfig', () => { - const baseConfig: Required = { + const baseConfig: Omit, 'devConfig'> & Pick = { timeouts: { scanMs: 30000, },