From f2cceb3150c1beb00a10ab4a6e7fbfb96b4b3062 Mon Sep 17 00:00:00 2001 From: Leszek Stachowski Date: Fri, 3 Oct 2025 00:26:40 +0200 Subject: [PATCH] expose `useReadMRZ` hook for `DocumentCameraScreen` (#1188) --- app/src/providers/selfClientProvider.tsx | 12 + .../screens/document/DocumentCameraScreen.tsx | 99 +------ app/src/utils/utils.ts | 17 -- .../src/flows/onboarding/read-mrz.ts | 86 ++++++ .../mobile-sdk-alpha/src/processing/mrz.ts | 13 + packages/mobile-sdk-alpha/src/types/events.ts | 18 ++ .../tests/flows/onboarding/read-mrz.test.ts | 251 ++++++++++++++++++ 7 files changed, 389 insertions(+), 107 deletions(-) create mode 100644 packages/mobile-sdk-alpha/tests/flows/onboarding/read-mrz.test.ts diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index ee4fbc8db..f56a80537 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -189,6 +189,18 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { logNFCEvent(level, event, context, details); }); + addListener(SdkEvents.DOCUMENT_MRZ_READ_SUCCESS, () => { + if (navigationRef.isReady()) { + navigationRef.navigate('DocumentNFCScan'); + } + }); + + addListener(SdkEvents.DOCUMENT_MRZ_READ_FAILURE, () => { + if (navigationRef.isReady()) { + navigationRef.navigate('DocumentCameraTrouble'); + } + }); + return map; }, []); diff --git a/app/src/screens/document/DocumentCameraScreen.tsx b/app/src/screens/document/DocumentCameraScreen.tsx index 310e8a3f5..17dd4228f 100644 --- a/app/src/screens/document/DocumentCameraScreen.tsx +++ b/app/src/screens/document/DocumentCameraScreen.tsx @@ -3,21 +3,23 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import LottieView from 'lottie-react-native'; -import React, { useCallback, useRef } from 'react'; -import { Platform, StyleSheet } from 'react-native'; +import React, { useRef } from 'react'; +import { StyleSheet } from 'react-native'; import { View, XStack, YStack } from 'tamagui'; -import { useIsFocused, useNavigation } from '@react-navigation/native'; +import { useIsFocused } from '@react-navigation/native'; import { - formatDateToYYMMDD, hasAnyValidRegisteredDocument, useSelfClient, } from '@selfxyz/mobile-sdk-alpha'; import { PassportEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; +import { + mrzReadInstructions, + useReadMRZ, +} from '@selfxyz/mobile-sdk-alpha/onboarding/read-mrz'; import passportScanAnimation from '@/assets/animations/passport_scan.json'; import { SecondaryButton } from '@/components/buttons/SecondaryButton'; -import type { PassportCameraProps } from '@/components/native/PassportCamera'; import { PassportCamera } from '@/components/native/PassportCamera'; import Additional from '@/components/typography/Additional'; import Description from '@/components/typography/Description'; @@ -25,99 +27,17 @@ import { Title } from '@/components/typography/Title'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import Scan from '@/images/icons/passport_camera_scan.svg'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; -import analytics from '@/utils/analytics'; import { black, slate400, slate800, white } from '@/utils/colors'; import { dinot } from '@/utils/fonts'; -import { checkScannedInfo } from '@/utils/utils'; - -const { trackEvent } = analytics(); const DocumentCameraScreen: React.FC = () => { const client = useSelfClient(); - const { useMRZStore } = client; - const { setMRZForNFC } = useMRZStore(); - const navigation = useNavigation(); const isFocused = useIsFocused(); // Add a ref to track when the camera screen is mounted const scanStartTimeRef = useRef(Date.now()); + const { onPassportRead } = useReadMRZ(scanStartTimeRef); - const onPassportRead = useCallback( - (error, result) => { - // Calculate scan duration in seconds with exactly 2 decimal places - const scanDurationSeconds = ( - (Date.now() - scanStartTimeRef.current) / - 1000 - ).toFixed(2); - - if (error) { - console.error(error); - trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { - reason: 'unknown_error', - error: error.message || 'Unknown error', - duration_seconds: parseFloat(scanDurationSeconds), - }); - //TODO: Add error handling here - return; - } - - if (!result) { - console.error('No result from passport scan'); - trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { - reason: 'invalid_input', - error: 'No result from scan', - duration_seconds: parseFloat(scanDurationSeconds), - }); - return; - } - - const { - documentNumber, - dateOfBirth, - dateOfExpiry, - documentType, - issuingCountry, - } = result; - - const formattedDateOfBirth = - Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfBirth) : dateOfBirth; - const formattedDateOfExpiry = - Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfExpiry) : dateOfExpiry; - - if ( - !checkScannedInfo( - documentNumber, - formattedDateOfBirth, - formattedDateOfExpiry, - ) - ) { - trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { - reason: 'invalid_format', - passportNumberLength: documentNumber.length, - dateOfBirthLength: formattedDateOfBirth.length, - dateOfExpiryLength: formattedDateOfExpiry.length, - duration_seconds: parseFloat(scanDurationSeconds), - }); - navigation.navigate('DocumentCameraTrouble'); - return; - } - - setMRZForNFC({ - passportNumber: documentNumber, - dateOfBirth: formattedDateOfBirth, - dateOfExpiry: formattedDateOfExpiry, - documentType: documentType?.trim() || '', - countryCode: issuingCountry?.trim().toUpperCase() || '', - }); - - trackEvent(PassportEvents.CAMERA_SCAN_SUCCESS, { - duration_seconds: parseFloat(scanDurationSeconds), - }); - - navigation.navigate('DocumentNFCScan'); - }, - [setMRZForNFC, navigation], - ); const navigateToLaunch = useHapticNavigation('Launch', { action: 'cancel', }); @@ -160,8 +80,7 @@ const DocumentCameraScreen: React.FC = () => { Open to the photograph page - Lay your document flat and position the machine readable text - in the viewfinder + {mrzReadInstructions()} diff --git a/app/src/utils/utils.ts b/app/src/utils/utils.ts index 66b1058ad..6dbfe8287 100644 --- a/app/src/utils/utils.ts +++ b/app/src/utils/utils.ts @@ -2,23 +2,6 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -export function checkScannedInfo( - passportNumber: string, - dateOfBirth: string, - dateOfExpiry: string, -): boolean { - if (passportNumber.length > 9) { - return false; - } - if (dateOfBirth.length !== 6) { - return false; - } - if (dateOfExpiry.length !== 6) { - return false; - } - return true; -} - // Redacts 9+ consecutive digits and MRZ-like blocks to reduce PII exposure export const sanitizeErrorMessage = (msg: string): string => { try { 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 dc4ae68ca..bd463ab5b 100644 --- a/packages/mobile-sdk-alpha/src/flows/onboarding/read-mrz.ts +++ b/packages/mobile-sdk-alpha/src/flows/onboarding/read-mrz.ts @@ -2,7 +2,93 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. +import { RefObject, useCallback } from 'react'; +import { Platform } from 'react-native'; + +import { PassportEvents } from '../../constants/analytics'; +import { useSelfClient } from '../../context'; +import { checkScannedInfo, formatDateToYYMMDD } from '../../processing/mrz'; +import { SdkEvents } from '../../types/events'; +import { MRZInfo } from '../../types/public'; + export { MRZScannerView, MRZScannerViewProps } from '../../components/MRZScannerView'; + export function mrzReadInstructions() { return 'Lay your document flat and position the machine readable text in the viewfinder'; } + +const calculateScanDurationSeconds = (scanStartTimeRef: RefObject) => { + if (!scanStartTimeRef.current) return '0.00'; + + // Calculate scan duration in seconds with exactly 2 decimal places + return ((Date.now() - scanStartTimeRef.current) / 1000).toFixed(2); +}; + +export function useReadMRZ(scanStartTimeRef: RefObject) { + const selfClient = useSelfClient(); + + return { + onPassportRead: useCallback( + (error: Error | null, result?: MRZInfo) => { + const scanDurationSeconds = calculateScanDurationSeconds(scanStartTimeRef); + + if (error) { + console.error(error); + + selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { + reason: 'unknown_error', + error: error.message || 'Unknown error', + duration_seconds: parseFloat(scanDurationSeconds), + }); + + // TODO: Add error handling here + return; + } + + if (!result) { + console.error('No result from passport scan'); + selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { + reason: 'invalid_input', + error: 'No result from scan', + duration_seconds: parseFloat(scanDurationSeconds), + }); + + return; + } + + const { documentNumber, dateOfBirth, dateOfExpiry, documentType, issuingCountry } = result; + + const formattedDateOfBirth = Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfBirth) : dateOfBirth; + const formattedDateOfExpiry = Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfExpiry) : dateOfExpiry; + + if (!checkScannedInfo(documentNumber, formattedDateOfBirth, formattedDateOfExpiry)) { + selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { + reason: 'invalid_format', + passportNumberLength: documentNumber.length, + dateOfBirthLength: formattedDateOfBirth.length, + dateOfExpiryLength: formattedDateOfExpiry.length, + duration_seconds: parseFloat(scanDurationSeconds), + }); + + selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_FAILURE); + return; + } + + selfClient.getMRZState().setMRZForNFC({ + passportNumber: documentNumber, + dateOfBirth: formattedDateOfBirth, + dateOfExpiry: formattedDateOfExpiry, + documentType: documentType?.trim() || '', + countryCode: issuingCountry?.trim().toUpperCase() || '', + }); + + selfClient.trackEvent(PassportEvents.CAMERA_SCAN_SUCCESS, { + duration_seconds: parseFloat(scanDurationSeconds), + }); + + selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_SUCCESS); + }, + [selfClient], + ), + }; +} diff --git a/packages/mobile-sdk-alpha/src/processing/mrz.ts b/packages/mobile-sdk-alpha/src/processing/mrz.ts index 63c1e521a..590352a44 100644 --- a/packages/mobile-sdk-alpha/src/processing/mrz.ts +++ b/packages/mobile-sdk-alpha/src/processing/mrz.ts @@ -185,6 +185,19 @@ function validateTD3CheckDigits(lines: string[]): Omit 9) { + return false; + } + if (dateOfBirth.length !== 6) { + return false; + } + if (dateOfExpiry.length !== 6) { + return false; + } + return true; +} + /** * Extract and validate MRZ information from a machine-readable zone string * Supports TD3 format (passports) with comprehensive validation diff --git a/packages/mobile-sdk-alpha/src/types/events.ts b/packages/mobile-sdk-alpha/src/types/events.ts index 008465e82..1024c6c26 100644 --- a/packages/mobile-sdk-alpha/src/types/events.ts +++ b/packages/mobile-sdk-alpha/src/types/events.ts @@ -108,6 +108,22 @@ export enum SdkEvents { * identify any issues that may arise. */ NFC_EVENT = 'NFC_EVENT', + + /** + * Emitted when document camera scan is successful and ready for NFC scanning. + * + * **Required:** Navigate to the DocumentNFCScan screen to continue the verification process. + * **Recommended:** This event is triggered after successful MRZ data extraction and validation. + */ + DOCUMENT_MRZ_READ_SUCCESS = 'DOCUMENT_MRZ_READ_SUCCESS', + + /** + * Emitted when document camera scan fails due to invalid MRZ data format. + * + * **Required:** Navigate to the DocumentCameraTrouble screen to show troubleshooting tips. + * **Recommended:** This event is triggered when MRZ data validation fails (invalid format, missing fields, etc.). + */ + DOCUMENT_MRZ_READ_FAILURE = 'DOCUMENT_MRZ_READ_FAILURE', } export interface SDKEventMap { @@ -152,6 +168,8 @@ export interface SDKEventMap { event: string; details?: Record; }; + [SdkEvents.DOCUMENT_MRZ_READ_SUCCESS]: undefined; + [SdkEvents.DOCUMENT_MRZ_READ_FAILURE]: undefined; } export type SDKEvent = keyof SDKEventMap; diff --git a/packages/mobile-sdk-alpha/tests/flows/onboarding/read-mrz.test.ts b/packages/mobile-sdk-alpha/tests/flows/onboarding/read-mrz.test.ts new file mode 100644 index 000000000..031f52e17 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/flows/onboarding/read-mrz.test.ts @@ -0,0 +1,251 @@ +// 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. + +/* @vitest-environment jsdom */ +import { Platform } from 'react-native'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PassportEvents } from '../../../src/constants/analytics'; +import { useReadMRZ } from '../../../src/flows/onboarding/read-mrz'; +import { SdkEvents } from '../../../src/types/events'; +import { MRZInfo } from '../../../src/types/public'; + +import { renderHook } from '@testing-library/react'; + +// React Native is already mocked in setup.ts + +// Mock the MRZ processing functions +vi.mock('../../../src/processing/mrz', () => ({ + checkScannedInfo: vi.fn(() => true), + formatDateToYYMMDD: vi.fn((date: string) => { + // Simple mock implementation for testing + if (date === '1974-08-12') return '740812'; + if (date === '2012-04-15') return '120415'; + return date; + }), +})); + +// Mock the context +vi.mock('../../../src/context', () => ({ + useSelfClient: vi.fn(), +})); + +describe('useReadMRZ', () => { + let mockSelfClient: any; + let mockMRZState: any; + let scanStartTimeRef: { current: number }; + let mockUseSelfClient: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Mock scan start time + scanStartTimeRef = { current: Date.now() - 2500 }; // 2.5 seconds ago + + // Mock MRZ state + mockMRZState = { + setMRZForNFC: vi.fn(), + }; + + // Mock self client + mockSelfClient = { + trackEvent: vi.fn(), + emit: vi.fn(), + getMRZState: vi.fn(() => mockMRZState), + }; + + // Mock the useSelfClient hook + const { useSelfClient } = await import('../../../src/context'); + mockUseSelfClient = vi.mocked(useSelfClient); + mockUseSelfClient.mockReturnValue(mockSelfClient); + }); + + it('handles successful MRZ read with valid data on iOS', () => { + // Mock Platform.OS to be 'ios' + vi.mocked(Platform).OS = 'ios'; + + const { result } = renderHook(() => useReadMRZ(scanStartTimeRef)); + const { onPassportRead } = result.current; + + const mockMRZInfo: MRZInfo = { + documentNumber: 'L898902C3', + dateOfBirth: '1974-08-12', + dateOfExpiry: '2012-04-15', + documentType: 'P', + issuingCountry: 'UTO', + validation: { + format: true, + passportNumberChecksum: true, + dateOfBirthChecksum: true, + dateOfExpiryChecksum: true, + compositeChecksum: true, + overall: true, + }, + }; + + // Call the callback with valid data + onPassportRead(null, mockMRZInfo); + + // Verify MRZ state was set correctly + expect(mockMRZState.setMRZForNFC).toHaveBeenCalledWith({ + passportNumber: 'L898902C3', + dateOfBirth: '740812', // Formatted for iOS + dateOfExpiry: '120415', // Formatted for iOS + documentType: 'P', + countryCode: 'UTO', + }); + + // Verify success analytics event was tracked + expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(PassportEvents.CAMERA_SCAN_SUCCESS, { + duration_seconds: expect.any(Number), + }); + + // Verify success event was emitted + expect(mockSelfClient.emit).toHaveBeenCalledWith(SdkEvents.DOCUMENT_MRZ_READ_SUCCESS); + }); + + it('handles successful MRZ read with valid data on Android', () => { + // Mock Platform.OS to be 'android' + vi.mocked(Platform).OS = 'android'; + + const { result } = renderHook(() => useReadMRZ(scanStartTimeRef)); + const { onPassportRead } = result.current; + + const mockMRZInfo: MRZInfo = { + documentNumber: 'L898902C3', + dateOfBirth: '740812', // Already in YYMMDD format + dateOfExpiry: '120415', // Already in YYMMDD format + documentType: 'P', + issuingCountry: 'UTO', + validation: { + format: true, + passportNumberChecksum: true, + dateOfBirthChecksum: true, + dateOfExpiryChecksum: true, + compositeChecksum: true, + overall: true, + }, + }; + + // Call the callback with valid data + onPassportRead(null, mockMRZInfo); + + // Verify MRZ state was set correctly (dates not formatted on Android) + expect(mockMRZState.setMRZForNFC).toHaveBeenCalledWith({ + passportNumber: 'L898902C3', + dateOfBirth: '740812', // Not formatted on Android + dateOfExpiry: '120415', // Not formatted on Android + documentType: 'P', + countryCode: 'UTO', + }); + + // Verify success analytics event was tracked + expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(PassportEvents.CAMERA_SCAN_SUCCESS, { + duration_seconds: expect.any(Number), + }); + + // Verify success event was emitted + expect(mockSelfClient.emit).toHaveBeenCalledWith(SdkEvents.DOCUMENT_MRZ_READ_SUCCESS); + }); + + it('trims whitespace from document type and country code', () => { + const { result } = renderHook(() => useReadMRZ(scanStartTimeRef)); + const { onPassportRead } = result.current; + + const mockMRZInfo: MRZInfo = { + documentNumber: 'L898902C3', + dateOfBirth: '740812', + dateOfExpiry: '120415', + documentType: ' P ', // With whitespace + issuingCountry: ' uto ', // With whitespace and lowercase + validation: { + format: true, + passportNumberChecksum: true, + dateOfBirthChecksum: true, + dateOfExpiryChecksum: true, + compositeChecksum: true, + overall: true, + }, + }; + + // Call the callback with valid data + onPassportRead(null, mockMRZInfo); + + // Verify MRZ state was set with trimmed and uppercased values + expect(mockMRZState.setMRZForNFC).toHaveBeenCalledWith({ + passportNumber: 'L898902C3', + dateOfBirth: '740812', + dateOfExpiry: '120415', + documentType: 'P', // Trimmed + countryCode: 'UTO', // Trimmed and uppercased + }); + }); + + it('handles empty document type and country code gracefully', () => { + const { result } = renderHook(() => useReadMRZ(scanStartTimeRef)); + const { onPassportRead } = result.current; + + const mockMRZInfo: MRZInfo = { + documentNumber: 'L898902C3', + dateOfBirth: '740812', + dateOfExpiry: '120415', + documentType: '', // Empty + issuingCountry: '', // Empty + validation: { + format: true, + passportNumberChecksum: true, + dateOfBirthChecksum: true, + dateOfExpiryChecksum: true, + compositeChecksum: true, + overall: true, + }, + }; + + // Call the callback with valid data + onPassportRead(null, mockMRZInfo); + + // Verify MRZ state was set with empty strings + expect(mockMRZState.setMRZForNFC).toHaveBeenCalledWith({ + passportNumber: 'L898902C3', + dateOfBirth: '740812', + dateOfExpiry: '120415', + documentType: '', // Empty string + countryCode: '', // Empty string + }); + }); + + it('calculates scan duration correctly', () => { + const { result } = renderHook(() => useReadMRZ(scanStartTimeRef)); + const { onPassportRead } = result.current; + + const mockMRZInfo: MRZInfo = { + documentNumber: 'L898902C3', + dateOfBirth: '740812', + dateOfExpiry: '120415', + documentType: 'P', + issuingCountry: 'UTO', + validation: { + format: true, + passportNumberChecksum: true, + dateOfBirthChecksum: true, + dateOfExpiryChecksum: true, + compositeChecksum: true, + overall: true, + }, + }; + + // Call the callback with valid data + onPassportRead(null, mockMRZInfo); + + // Verify the duration was calculated and passed to analytics + expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(PassportEvents.CAMERA_SCAN_SUCCESS, { + duration_seconds: expect.any(Number), + }); + + // The duration should be approximately 2.5 seconds (2500ms / 1000) + const trackEventCall = mockSelfClient.trackEvent.mock.calls[0]; + const durationSeconds = trackEventCall[1].duration_seconds; + expect(durationSeconds).toBeCloseTo(2.5, 1); + }); +});