From f54c668274a89ac629fb764ae3f7a2b7a839f024 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:36:09 +0530 Subject: [PATCH 01/12] SELF-1645: Disable Verify button for expired documents (#1497) * Disable Verify button for expired documents * coderabbit feedbacks --- app/src/components/homescreen/IdCard.tsx | 172 +------------ app/src/screens/verification/ProveScreen.tsx | 58 ++++- app/src/utils/documentAttributes.ts | 236 ++++++++++++++++++ .../buttons/HeldPrimaryButtonProveScreen.tsx | 21 +- 4 files changed, 309 insertions(+), 178 deletions(-) create mode 100644 app/src/utils/documentAttributes.ts diff --git a/app/src/components/homescreen/IdCard.tsx b/app/src/components/homescreen/IdCard.tsx index a9956b165..1a9cfbffc 100644 --- a/app/src/components/homescreen/IdCard.tsx +++ b/app/src/components/homescreen/IdCard.tsx @@ -8,10 +8,6 @@ import { Dimensions } from 'react-native'; import { Separator, Text, XStack, YStack } from 'tamagui'; import type { AadhaarData } from '@selfxyz/common'; -import { - attributeToPosition, - attributeToPosition_ID, -} from '@selfxyz/common/constants'; import type { PassportData } from '@selfxyz/common/types/passport'; import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types'; import { @@ -28,6 +24,11 @@ import EPassport from '@selfxyz/mobile-sdk-alpha/svgs/icons/epassport.svg'; import LogoGray from '@/assets/images/logo_gray.svg'; import { SvgXml } from '@/components/homescreen/SvgXmlWrapper'; +import { + formatDateFromYYMMDD, + getDocumentAttributes, + getNameAndSurname, +} from '@/utils/documentAttributes'; // Import the logo SVG as a string const logoSvg = ` @@ -332,6 +333,7 @@ const IdCardLayout: FC = ({ name="DOB" value={formatDateFromYYMMDD( getDocumentAttributes(idDocument).dobSlice, + true, )} maskValue="XX/XX/XXXX" hidden={hidden} @@ -342,7 +344,6 @@ const IdCardLayout: FC = ({ name="EXPIRY DATE" value={formatDateFromYYMMDD( getDocumentAttributes(idDocument).expiryDateSlice, - true, )} maskValue="XX/XX/XXXX" hidden={hidden} @@ -520,164 +521,3 @@ const IdAttribute: FC = ({ }; export default IdCardLayout; - -// Helper functions to safely extract document data -function getDocumentAttributes(document: PassportData | AadhaarData) { - if (isAadhaarDocument(document)) { - return getAadhaarAttributes(document); - } else if (isMRZDocument(document)) { - return getPassportAttributes(document.mrz, document.documentCategory); - } else { - // Fallback for unknown document types - return { - nameSlice: '', - dobSlice: '', - yobSlice: '', - issuingStateSlice: '', - nationalitySlice: '', - passNoSlice: '', - sexSlice: '', - expiryDateSlice: '', - isPassportType: false, - }; - } -} - -function getAadhaarAttributes(document: AadhaarData) { - const extractedFields = document.extractedFields; - // For Aadhaar, we format the name to work with the existing getNameAndSurname function - // We'll put the full name in the "surname" position and leave names empty - const fullName = extractedFields?.name || ''; - const nameSliceFormatted = fullName ? `${fullName}<<` : ''; // Format like MRZ - - // Format DOB to YYMMDD for consistency with passport format - let dobFormatted = ''; - if (extractedFields?.dob && extractedFields?.mob && extractedFields?.yob) { - const year = - extractedFields.yob.length === 4 - ? extractedFields.yob.slice(-2) - : extractedFields.yob; - const month = extractedFields.mob.padStart(2, '0'); - const day = extractedFields.dob.padStart(2, '0'); - dobFormatted = `${year}${month}${day}`; - } - - return { - nameSlice: nameSliceFormatted, - dobSlice: dobFormatted, - yobSlice: extractedFields?.yob || '', - issuingStateSlice: extractedFields?.state || '', - nationalitySlice: 'IND', // Aadhaar is always Indian - passNoSlice: extractedFields?.aadhaarLast4Digits || '', - sexSlice: - extractedFields?.gender === 'M' - ? 'M' - : extractedFields?.gender === 'F' - ? 'F' - : extractedFields?.gender || '', - expiryDateSlice: '', // Aadhaar doesn't expire - isPassportType: false, - }; -} - -function getPassportAttributes(mrz: string, documentCategory: string) { - const isPassportType = documentCategory === 'passport'; - const attributePositions = isPassportType - ? attributeToPosition - : attributeToPosition_ID; - - const nameSlice = mrz.slice( - attributePositions.name[0], - attributePositions.name[1], - ); - const dobSlice = mrz.slice( - attributePositions.date_of_birth[0], - attributePositions.date_of_birth[1] + 1, - ); - const yobSlice = mrz.slice( - attributePositions.date_of_birth[0], - attributePositions.date_of_birth[0] + 1, - ); - const issuingStateSlice = mrz.slice( - attributePositions.issuing_state[0], - attributePositions.issuing_state[1] + 1, - ); - const nationalitySlice = mrz.slice( - attributePositions.nationality[0], - attributePositions.nationality[1] + 1, - ); - const passNoSlice = mrz.slice( - attributePositions.passport_number[0], - attributePositions.passport_number[1] + 1, - ); - const sexSlice = mrz.slice( - attributePositions.gender[0], - attributePositions.gender[1] + 1, - ); - const expiryDateSlice = mrz.slice( - attributePositions.expiry_date[0], - attributePositions.expiry_date[1] + 1, - ); - return { - nameSlice, - dobSlice, - yobSlice, - issuingStateSlice, - nationalitySlice, - passNoSlice, - sexSlice, - expiryDateSlice, - isPassportType, - }; -} - -function getNameAndSurname(nameSlice: string) { - // Split by double << to separate surname from names - const parts = nameSlice.split('<<'); - if (parts.length < 2) { - return { surname: [], names: [] }; - } - - // First part is surname, second part contains names separated by single < - const surname = parts[0].replace(/ name.length > 0); - - return { - surname: surname ? [surname] : [], - names: names[0] ? [names[0]] : [], - }; -} - -function formatDateFromYYMMDD( - dateString: string, - isExpiry: boolean = false, -): string { - if (dateString.length !== 6) { - return dateString; - } - - const yy = parseInt(dateString.substring(0, 2), 10); - const mm = dateString.substring(2, 4); - const dd = dateString.substring(4, 6); - - const currentYear = new Date().getFullYear(); - const century = Math.floor(currentYear / 100) * 100; - let year = century + yy; - - if (isExpiry) { - // For expiry: if year is in the past, assume next century - if (year < currentYear) { - year += 100; - } - } else { - // For birth: if year is in the future, assume previous century - if (year > currentYear) { - year -= 100; - } - } - - return `${dd}/${mm}/${year}`; -} diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 4681962bd..d2a96bb07 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -21,9 +21,10 @@ import { useIsFocused, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Eye, EyeOff } from '@tamagui/lucide-icons'; +import { isMRZDocument } from '@selfxyz/common'; import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; import { formatEndpoint } from '@selfxyz/common/utils/scope'; -import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; +import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import miscAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json'; import { BodyText, @@ -48,6 +49,10 @@ import { import { getPointsAddress } from '@/services/points'; import { useProofHistoryStore } from '@/stores/proofHistoryStore'; import { ProofStatus } from '@/stores/proofTypes'; +import { + checkDocumentExpiration, + getDocumentAttributes, +} from '@/utils/documentAttributes'; import { formatUserId } from '@/utils/formatUserId'; const ProveScreen: React.FC = () => { @@ -64,6 +69,8 @@ const ProveScreen: React.FC = () => { const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0); const [scrollViewHeight, setScrollViewHeight] = useState(0); const [showFullAddress, setShowFullAddress] = useState(false); + const [isDocumentExpired, setIsDocumentExpired] = useState(false); + const isDocumentExpiredRef = useRef(false); const scrollViewRef = useRef(null); const isContentShorterThanScrollView = useMemo( @@ -115,11 +122,43 @@ const ProveScreen: React.FC = () => { setDefaultDocumentTypeIfNeeded(); - if (selectedAppRef.current?.sessionId !== selectedApp.sessionId) { - provingStore.init(selfClient, 'disclose'); - } - selectedAppRef.current = selectedApp; - }, [selectedApp, isFocused, provingStore, selfClient]); + const checkExpirationAndInit = async () => { + let isExpired = false; + try { + const selectedDocument = await loadSelectedDocument(selfClient); + if (!selectedDocument || !isMRZDocument(selectedDocument.data)) { + setIsDocumentExpired(false); + isExpired = false; + isDocumentExpiredRef.current = false; + } else { + const { data: passportData } = selectedDocument; + const attributes = getDocumentAttributes(passportData); + const expiryDateSlice = attributes.expiryDateSlice; + isExpired = checkDocumentExpiration(expiryDateSlice); + setIsDocumentExpired(isExpired); + isDocumentExpiredRef.current = isExpired; + } + } catch (error) { + console.error('Error checking document expiration:', error); + setIsDocumentExpired(false); + isExpired = false; + isDocumentExpiredRef.current = false; + } + + if ( + !isExpired && + selectedAppRef.current?.sessionId !== selectedApp.sessionId + ) { + provingStore.init(selfClient, 'disclose'); + } + selectedAppRef.current = selectedApp; + }; + + checkExpirationAndInit(); + //removed provingStore from dependencies because it causes infinite re-render on longpressing the button + //as it sets provingStore.setUserConfirmed() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedApp?.sessionId, isFocused, selfClient]); // Enhance selfApp with user's points address if not already set useEffect(() => { @@ -206,7 +245,11 @@ const ProveScreen: React.FC = () => { const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom; - if (isCloseToBottom && !hasScrolledToBottom) { + if ( + isCloseToBottom && + !hasScrolledToBottom && + !isDocumentExpiredRef.current + ) { setHasScrolledToBottom(true); buttonTap(); trackEvent(ProofEvents.PROOF_DISCLOSURES_SCROLLED, { @@ -425,6 +468,7 @@ const ProveScreen: React.FC = () => { selectedAppSessionId={selectedApp?.sessionId} hasScrolledToBottom={hasScrolledToBottom} isReadyToProve={isReadyToProve} + isDocumentExpired={isDocumentExpired} /> diff --git a/app/src/utils/documentAttributes.ts b/app/src/utils/documentAttributes.ts new file mode 100644 index 000000000..ad73e9c4d --- /dev/null +++ b/app/src/utils/documentAttributes.ts @@ -0,0 +1,236 @@ +// 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 type { AadhaarData } from '@selfxyz/common'; +import { + attributeToPosition, + attributeToPosition_ID, +} from '@selfxyz/common/constants'; +import type { PassportData } from '@selfxyz/common/types/passport'; +import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types'; + +export interface DocumentAttributes { + nameSlice: string; + dobSlice: string; + yobSlice: string; + issuingStateSlice: string; + nationalitySlice: string; + passNoSlice: string; + sexSlice: string; + expiryDateSlice: string; + isPassportType: boolean; +} + +/** + * Extracts attributes from Aadhaar document data + */ +function getAadhaarAttributes(document: AadhaarData): DocumentAttributes { + const extractedFields = document.extractedFields; + // For Aadhaar, we format the name to work with the existing getNameAndSurname function + // We'll put the full name in the "surname" position and leave names empty + const fullName = extractedFields?.name || ''; + const nameSliceFormatted = fullName ? `${fullName}<<` : ''; // Format like MRZ + + // Format DOB to YYMMDD for consistency with passport format + let dobFormatted = ''; + if (extractedFields?.dob && extractedFields?.mob && extractedFields?.yob) { + const year = + extractedFields.yob.length === 4 + ? extractedFields.yob.slice(-2) + : extractedFields.yob; + const month = extractedFields.mob.padStart(2, '0'); + const day = extractedFields.dob.padStart(2, '0'); + dobFormatted = `${year}${month}${day}`; + } + + return { + nameSlice: nameSliceFormatted, + dobSlice: dobFormatted, + yobSlice: extractedFields?.yob || '', + issuingStateSlice: extractedFields?.state || '', + nationalitySlice: 'IND', // Aadhaar is always Indian + passNoSlice: extractedFields?.aadhaarLast4Digits || '', + sexSlice: + extractedFields?.gender === 'M' + ? 'M' + : extractedFields?.gender === 'F' + ? 'F' + : extractedFields?.gender || '', + expiryDateSlice: '', // Aadhaar doesn't expire + isPassportType: false, + }; +} + +/** + * Extracts attributes from MRZ string (passport or ID card) + */ +function getPassportAttributes( + mrz: string, + documentCategory: string, +): DocumentAttributes { + const isPassportType = documentCategory === 'passport'; + const attributePositions = isPassportType + ? attributeToPosition + : attributeToPosition_ID; + + const nameSlice = mrz.slice( + attributePositions.name[0], + attributePositions.name[1], + ); + const dobSlice = mrz.slice( + attributePositions.date_of_birth[0], + attributePositions.date_of_birth[1] + 1, + ); + const yobSlice = mrz.slice( + attributePositions.date_of_birth[0], + attributePositions.date_of_birth[0] + 2, + ); + const issuingStateSlice = mrz.slice( + attributePositions.issuing_state[0], + attributePositions.issuing_state[1] + 1, + ); + const nationalitySlice = mrz.slice( + attributePositions.nationality[0], + attributePositions.nationality[1] + 1, + ); + const passNoSlice = mrz.slice( + attributePositions.passport_number[0], + attributePositions.passport_number[1] + 1, + ); + const sexSlice = mrz.slice( + attributePositions.gender[0], + attributePositions.gender[1] + 1, + ); + const expiryDateSlice = mrz.slice( + attributePositions.expiry_date[0], + attributePositions.expiry_date[1] + 1, + ); + return { + nameSlice, + dobSlice, + yobSlice, + issuingStateSlice, + nationalitySlice, + passNoSlice, + sexSlice, + expiryDateSlice, + isPassportType, + }; +} + +/** + * Checks if a document expiration date (in YYMMDD format) has passed. + * we assume dateOfExpiry is this century because ICAO standard for biometric passport + * became standard around 2002 + * @param dateOfExpiry - Expiration date in YYMMDD format from MRZ + * @returns true if the document is expired, false otherwise + */ +export function checkDocumentExpiration(dateOfExpiry: string): boolean { + if (!dateOfExpiry || dateOfExpiry.length !== 6) { + return false; // Invalid format, don't treat as expired + } + + const year = parseInt(dateOfExpiry.slice(0, 2), 10); + const fullyear = 2000 + year; + const month = parseInt(dateOfExpiry.slice(2, 4), 10) - 1; // JS months are 0-indexed + const day = parseInt(dateOfExpiry.slice(4, 6), 10); + + const expiryDateUTC = new Date(Date.UTC(fullyear, month, day, 0, 0, 0, 0)); + const nowUTC = new Date(); + const todayUTC = new Date( + Date.UTC( + nowUTC.getFullYear(), + nowUTC.getMonth(), + nowUTC.getDate(), + 0, + 0, + 0, + 0, + ), + ); + + return todayUTC >= expiryDateUTC; +} + +/** + * Formats date from YYMMDD format to DD/MM/YYYY format + * For expiry (isDOB is false), we assume its this century because ICAO standard for biometric passport + * became standard around 2002 + */ +export function formatDateFromYYMMDD( + dateString: string, + isDOB: boolean = false, +): string { + if (dateString.length !== 6) { + return dateString; + } + + const yy = parseInt(dateString.substring(0, 2), 10); + const mm = dateString.substring(2, 4); + const dd = dateString.substring(4, 6); + + const currentYear = new Date().getFullYear(); + const century = Math.floor(currentYear / 100) * 100; + let year = century + yy; + + if (isDOB) { + // For birth: if year is in the future, assume previous century + if (year > currentYear) { + year -= 100; + } + } + + return `${dd}/${mm}/${year}`; +} + +// Helper functions to safely extract document data +export function getDocumentAttributes( + document: PassportData | AadhaarData, +): DocumentAttributes { + if (isAadhaarDocument(document)) { + return getAadhaarAttributes(document); + } else if (isMRZDocument(document)) { + return getPassportAttributes(document.mrz, document.documentCategory); + } else { + // Fallback for unknown document types + return { + nameSlice: '', + dobSlice: '', + yobSlice: '', + issuingStateSlice: '', + nationalitySlice: '', + passNoSlice: '', + sexSlice: '', + expiryDateSlice: '', + isPassportType: false, + }; + } +} + +/** + * Parses name from MRZ format (surname< name.length > 0); + + return { + surname: surname ? [surname] : [], + names: names[0] ? [names[0]] : [], + }; +} diff --git a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx index 72be9f09f..9ee8068a8 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx @@ -19,6 +19,7 @@ interface HeldPrimaryButtonProveScreenProps { selectedAppSessionId: string | undefined | null; hasScrolledToBottom: boolean; isReadyToProve: boolean; + isDocumentExpired: boolean; } interface ButtonContext { @@ -26,6 +27,7 @@ interface ButtonContext { hasScrolledToBottom: boolean; isReadyToProve: boolean; onVerify: () => void; + isDocumentExpired: boolean; } type ButtonEvent = @@ -34,6 +36,7 @@ type ButtonEvent = selectedAppSessionId: string | undefined | null; hasScrolledToBottom: boolean; isReadyToProve: boolean; + isDocumentExpired: boolean; } | { type: 'VERIFY' }; @@ -51,6 +54,7 @@ const buttonMachine = createMachine( hasScrolledToBottom: false, isReadyToProve: false, onVerify: input.onVerify, + isDocumentExpired: false, }), on: { PROPS_UPDATED: { @@ -88,7 +92,7 @@ const buttonMachine = createMachine( }, { target: 'ready', - guard: ({ context }) => context.isReadyToProve, + guard: ({ context }) => context.isReadyToProve && !context.isDocumentExpired, }, ], after: { @@ -107,7 +111,7 @@ const buttonMachine = createMachine( }, { target: 'ready', - guard: ({ context }) => context.isReadyToProve, + guard: ({ context }) => context.isReadyToProve && !context.isDocumentExpired, }, ], after: { @@ -126,7 +130,7 @@ const buttonMachine = createMachine( }, { target: 'ready', - guard: ({ context }) => context.isReadyToProve, + guard: ({ context }) => context.isReadyToProve && !context.isDocumentExpired, }, ], }, @@ -167,12 +171,14 @@ const buttonMachine = createMachine( if ( context.selectedAppSessionId !== event.selectedAppSessionId || context.hasScrolledToBottom !== event.hasScrolledToBottom || - context.isReadyToProve !== event.isReadyToProve + context.isReadyToProve !== event.isReadyToProve || + context.isDocumentExpired !== event.isDocumentExpired ) { return { selectedAppSessionId: event.selectedAppSessionId, hasScrolledToBottom: event.hasScrolledToBottom, isReadyToProve: event.isReadyToProve, + isDocumentExpired: event.isDocumentExpired, }; } } @@ -190,6 +196,7 @@ export const HeldPrimaryButtonProveScreen: React.FC { const [state, send] = useMachine(buttonMachine, { input: { onVerify }, @@ -201,12 +208,16 @@ export const HeldPrimaryButtonProveScreen: React.FC { + if (isDocumentExpired) { + return 'Document expired'; + } if (state.matches('waitingForSession')) { return ( From 4d04f48a06bcb21346ca6e2fcd5809a2e2e9feb2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:07:07 -0800 Subject: [PATCH 02/12] chore: bump mobile app version to 2.9.5 (#1504) Update build numbers and deployment timestamps after successful deployment. Co-authored-by: github-actions[bot] --- app/version.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/version.json b/app/version.json index 1fce89e44..d32a5b292 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 193, - "lastDeployed": "2025-12-06T09:48:56.530Z" + "build": 194, + "lastDeployed": "2025-12-14T22:52:48.122Z" }, "android": { - "build": 125, - "lastDeployed": "2025-12-14T07:58:56.243Z" + "build": 126, + "lastDeployed": "2025-12-14T22:52:48.122Z" } } From db9bce826272c70f79cef16b67a1086d8cadea09 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:28:59 +0530 Subject: [PATCH 03/12] fix returnNewDateString() (#1505) --- common/src/utils/aadhaar/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/utils/aadhaar/utils.ts b/common/src/utils/aadhaar/utils.ts index f86a913ad..994797439 100644 --- a/common/src/utils/aadhaar/utils.ts +++ b/common/src/utils/aadhaar/utils.ts @@ -398,7 +398,7 @@ export async function getAadharRegistrationWindow() { } export function returnNewDateString(timestamp?: string): string { - const newDate = timestamp ? new Date(+timestamp * 1000) : new Date(); + const newDate = timestamp ? new Date(+timestamp) : new Date(); // Convert the UTC date to IST by adding 5 hours and 30 minutes const offsetHours = 5; From adc002b91d2b453558944412138ad8f64bfb57b7 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Tue, 16 Dec 2025 01:19:13 +0530 Subject: [PATCH 04/12] Hide Secondary button in AadhaarUploadError screen (#1506) --- .../screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx b/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx index bb4bba46e..47098178c 100644 --- a/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx +++ b/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx @@ -99,7 +99,7 @@ const AadhaarUploadErrorScreen: React.FC = () => { Try Again - + {/* { trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType }); @@ -108,7 +108,7 @@ const AadhaarUploadErrorScreen: React.FC = () => { > Need Help? - + */} From e5e7b108a0baf4ad07af119db6b3aad80d24dfd0 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 15 Dec 2025 18:21:36 -0800 Subject: [PATCH 05/12] SELF-1553: Add back navigation to QR scanner view (#1508) * Add back navigation to QR scanner view * linting * remove custom hook. use default package --- app/src/hooks/useSafeAreaInsets.ts | 16 ------------ app/src/navigation/verification.ts | 1 + .../settings/ShowRecoveryPhraseScreen.tsx | 2 +- .../aadhaar/AadhaarUploadErrorScreen.tsx | 6 +---- .../selection/CountryPickerScreen.tsx | 3 +-- .../verification/QRCodeViewFinderScreen.tsx | 26 +++++++++++++++++++ 6 files changed, 30 insertions(+), 24 deletions(-) delete mode 100644 app/src/hooks/useSafeAreaInsets.ts diff --git a/app/src/hooks/useSafeAreaInsets.ts b/app/src/hooks/useSafeAreaInsets.ts deleted file mode 100644 index 5be2966a8..000000000 --- a/app/src/hooks/useSafeAreaInsets.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { Platform } from 'react-native'; -import { useSafeAreaInsets as useSafeAreaInsetsOriginal } from 'react-native-safe-area-context'; - -// gives bare minimums in case safe area doesnt provide for example space for status bar icons. -export function useSafeAreaInsets() { - const insets = useSafeAreaInsetsOriginal(); - const minimum = Platform.select({ ios: 54, android: 26, web: 48 }); - return { - ...insets, - top: Math.max(insets.top, minimum || 0), - }; -} diff --git a/app/src/navigation/verification.ts b/app/src/navigation/verification.ts index cb4ad8a2b..87209a5e0 100644 --- a/app/src/navigation/verification.ts +++ b/app/src/navigation/verification.ts @@ -44,6 +44,7 @@ const verificationScreens = { options: { headerShown: false, animation: 'slide_from_bottom', + gestureEnabled: false, } as NativeStackNavigationOptions, }, }; diff --git a/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx b/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx index 5d74f71a9..54b6adef8 100644 --- a/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx +++ b/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx @@ -3,6 +3,7 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import React, { useCallback, useEffect, useRef } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Clipboard from '@react-native-clipboard/clipboard'; import type { RecoveryPhraseVariant } from '@selfxyz/euclid'; @@ -12,7 +13,6 @@ import { Description } from '@selfxyz/mobile-sdk-alpha/components'; import Mnemonic from '@/components/Mnemonic'; import useMnemonic from '@/hooks/useMnemonic'; -import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import { useSettingStore } from '@/stores/settingStore'; import { IS_EUCLID_ENABLED } from '@/utils/devUtils'; diff --git a/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx b/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx index 47098178c..35e8d1726 100644 --- a/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx +++ b/app/src/screens/documents/aadhaar/AadhaarUploadErrorScreen.tsx @@ -8,11 +8,7 @@ import type { RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native'; import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; -import { - BodyText, - PrimaryButton, - SecondaryButton, -} from '@selfxyz/mobile-sdk-alpha/components'; +import { BodyText, PrimaryButton } from '@selfxyz/mobile-sdk-alpha/components'; import { AadhaarEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; import { black, diff --git a/app/src/screens/documents/selection/CountryPickerScreen.tsx b/app/src/screens/documents/selection/CountryPickerScreen.tsx index 57e358ff9..fe914bffa 100644 --- a/app/src/screens/documents/selection/CountryPickerScreen.tsx +++ b/app/src/screens/documents/selection/CountryPickerScreen.tsx @@ -3,11 +3,10 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import type React from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import SDKCountryPickerScreen from '@selfxyz/mobile-sdk-alpha/onboarding/country-picker-screen'; -import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets'; - type CountryPickerScreenComponent = React.FC & { statusBar: typeof SDKCountryPickerScreen.statusBar; }; diff --git a/app/src/screens/verification/QRCodeViewFinderScreen.tsx b/app/src/screens/verification/QRCodeViewFinderScreen.tsx index f8de835fd..eea105562 100644 --- a/app/src/screens/verification/QRCodeViewFinderScreen.tsx +++ b/app/src/screens/verification/QRCodeViewFinderScreen.tsx @@ -5,6 +5,7 @@ import LottieView from 'lottie-react-native'; import React, { useCallback, useState } from 'react'; import { StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { View, XStack, YStack } from 'tamagui'; import { useFocusEffect, @@ -30,8 +31,10 @@ import qrScanAnimation from '@/assets/animations/qr_scan.json'; import QRScan from '@/assets/icons/qr_code.svg'; import type { QRCodeScannerViewProps } from '@/components/native/QRCodeScanner'; import { QRCodeScannerView } from '@/components/native/QRCodeScanner'; +import { NavBar } from '@/components/navbar/BaseNavBar'; import useConnectionModal from '@/hooks/useConnectionModal'; import useHapticNavigation from '@/hooks/useHapticNavigation'; +import { buttonTap } from '@/integrations/haptics'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { RootStackParamList } from '@/navigation'; import { parseAndValidateUrlParams } from '@/navigation/deeplinks'; @@ -44,6 +47,7 @@ const QRCodeViewFinderScreen: React.FC = () => { useNavigation>(); const isFocused = useIsFocused(); const [doneScanningQR, setDoneScanningQR] = useState(false); + const { top: safeAreaTop } = useSafeAreaInsets(); const navigateToProve = useHapticNavigation('Prove'); // This resets to the default state when we navigate back to this screen @@ -53,6 +57,11 @@ const QRCodeViewFinderScreen: React.FC = () => { }, []), ); + const handleGoBack = useCallback(() => { + buttonTap(); + navigation.goBack(); + }, [navigation]); + const onQRData = useCallback( async (error, uri) => { if (doneScanningQR) { @@ -129,6 +138,23 @@ const QRCodeViewFinderScreen: React.FC = () => { <> + + + {shouldRenderCamera && ( <> From 50dba6de15eff156abc98ddfebf196c0393d5ec6 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 15 Dec 2025 20:44:59 -0800 Subject: [PATCH 06/12] SELF-1665: address sentry replay preview (#1509) * fix sentry replays * clean up comments --- app/src/config/sentry.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/config/sentry.ts b/app/src/config/sentry.ts index a190fcbd1..9d17bee56 100644 --- a/app/src/config/sentry.ts +++ b/app/src/config/sentry.ts @@ -11,6 +11,7 @@ import { consoleLoggingIntegration, feedbackIntegration, init as sentryInit, + mobileReplayIntegration, withScope, wrap, } from '@sentry/react-native'; @@ -164,6 +165,11 @@ export const initSentry = () => { return event; }, integrations: [ + mobileReplayIntegration({ + maskAllText: true, + maskAllImages: false, + maskAllVectors: false, + }), consoleLoggingIntegration({ levels: ['log', 'error', 'warn', 'info', 'debug'], }), From da733163f9dc9dbc6e4579e406b79f16216e3400 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 16 Dec 2025 09:33:12 -0800 Subject: [PATCH 07/12] SELF-1268: Fix paste button on recovery screen (#1513) * Fix paste button on recovery screen * Improve recovery paste button accessibility * recovery screen bugfix * simplify fix. remove pressable logic --- app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx b/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx index 3f3e5e638..c313d667a 100644 --- a/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx +++ b/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx @@ -48,7 +48,8 @@ const RecoverWithPhraseScreen: React.FC = () => { const [restoring, setRestoring] = useState(false); const onPaste = useCallback(async () => { const clipboard = (await Clipboard.getString()).trim(); - if (ethers.Mnemonic.isValidMnemonic(clipboard)) { + // bugfix: perform a simple clipboard check; ethers.Mnemonic.isValidMnemonic doesn't work + if (clipboard) { setMnemonic(clipboard); Keyboard.dismiss(); } From 6b9e888c9bba22a7061c2b247d21882b6c474d88 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Wed, 17 Dec 2025 01:17:03 +0530 Subject: [PATCH 08/12] update PCR0Manager (#1514) --- common/src/constants/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/constants/constants.ts b/common/src/constants/constants.ts index 30f197e2a..4e769c0d2 100644 --- a/common/src/constants/constants.ts +++ b/common/src/constants/constants.ts @@ -161,7 +161,7 @@ export const OFAC_TREE_LEVELS = 64; // we make it global here because passing it to generateCircuitInputsRegister caused trouble export const PASSPORT_ATTESTATION_ID = '1'; -export const PCR0_MANAGER_ADDRESS = '0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717'; +export const PCR0_MANAGER_ADDRESS = '0xE36d4EE5Fd3916e703A46C21Bb3837dB7680C8B8'; export const REDIRECT_URL = 'https://redirect.self.xyz'; From 35fdc691174d5382e0c2bc624794681450c419b8 Mon Sep 17 00:00:00 2001 From: Leszek Stachowski Date: Tue, 16 Dec 2025 23:09:13 +0100 Subject: [PATCH 09/12] CountryPickerScreen: remove info icon (#1515) --- app/package.json | 2 +- packages/mobile-sdk-alpha/package.json | 2 +- .../src/flows/onboarding/country-picker-screen.tsx | 1 + yarn.lock | 12 ++++++------ 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/package.json b/app/package.json index 16b8d12b9..faa5c95f3 100644 --- a/app/package.json +++ b/app/package.json @@ -105,7 +105,7 @@ "@segment/analytics-react-native": "^2.21.2", "@segment/sovran-react-native": "^1.1.3", "@selfxyz/common": "workspace:^", - "@selfxyz/euclid": "^0.6.0", + "@selfxyz/euclid": "^0.6.1", "@selfxyz/mobile-sdk-alpha": "workspace:^", "@sentry/react": "^9.32.0", "@sentry/react-native": "7.0.1", diff --git a/packages/mobile-sdk-alpha/package.json b/packages/mobile-sdk-alpha/package.json index c89429b80..6105fb9c7 100644 --- a/packages/mobile-sdk-alpha/package.json +++ b/packages/mobile-sdk-alpha/package.json @@ -151,7 +151,7 @@ "dependencies": { "@babel/runtime": "^7.28.3", "@selfxyz/common": "workspace:^", - "@selfxyz/euclid": "^0.6.0", + "@selfxyz/euclid": "^0.6.1", "@xstate/react": "^5.0.5", "node-forge": "^1.3.1", "react-native-nfc-manager": "^3.17.1", diff --git a/packages/mobile-sdk-alpha/src/flows/onboarding/country-picker-screen.tsx b/packages/mobile-sdk-alpha/src/flows/onboarding/country-picker-screen.tsx index c4c041ca3..6eb139d5d 100644 --- a/packages/mobile-sdk-alpha/src/flows/onboarding/country-picker-screen.tsx +++ b/packages/mobile-sdk-alpha/src/flows/onboarding/country-picker-screen.tsx @@ -74,6 +74,7 @@ const CountryPickerScreen: React.FC & { statusBar: typeof CountryPicke onClose={selfClient.goBack} onInfoPress={() => selfClient.trackEvent(DocumentEvents.COUNTRY_HELP_TAPPED)} onSearchChange={onSearchChange} + showInfoIcon={false} /> ); }; diff --git a/yarn.lock b/yarn.lock index 698912beb..02c287069 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8499,16 +8499,16 @@ __metadata: languageName: unknown linkType: soft -"@selfxyz/euclid@npm:^0.6.0": - version: 0.6.0 - resolution: "@selfxyz/euclid@npm:0.6.0" +"@selfxyz/euclid@npm:^0.6.1": + version: 0.6.1 + resolution: "@selfxyz/euclid@npm:0.6.1" peerDependencies: react: ">=18.2.0" react-native: ">=0.72.0" react-native-blur-effect: ^1.1.3 react-native-svg: ">=15.14.0" react-native-webview: ^13.16.0 - checksum: 10c0/2dd34f96f75e7641806bb8398a54c4ad666c4b953bf83810cda6ba85ded88780cc30ff2b1bd8dcbb1131d67f8a8cf05f955be5491a7ad706b0360a6a37b9c003 + checksum: 10c0/8e62c3c01a439f82de5327af45bd55acb741fbc99c9c87ec6726a37b8bccd6ce0a6bef507aaa906be242c8df22e424dc8e32b93a8e9365bbd07e10ecfe65427b languageName: node linkType: hard @@ -8550,7 +8550,7 @@ __metadata: "@segment/analytics-react-native": "npm:^2.21.2" "@segment/sovran-react-native": "npm:^1.1.3" "@selfxyz/common": "workspace:^" - "@selfxyz/euclid": "npm:^0.6.0" + "@selfxyz/euclid": "npm:^0.6.1" "@selfxyz/mobile-sdk-alpha": "workspace:^" "@sentry/react": "npm:^9.32.0" "@sentry/react-native": "npm:7.0.1" @@ -8674,7 +8674,7 @@ __metadata: dependencies: "@babel/runtime": "npm:^7.28.3" "@selfxyz/common": "workspace:^" - "@selfxyz/euclid": "npm:^0.6.0" + "@selfxyz/euclid": "npm:^0.6.1" "@testing-library/react": "npm:^14.1.2" "@types/react": "npm:^18.3.4" "@types/react-dom": "npm:^18.3.0" From 5321f3757b9b489443e7b16e3ba38c2679acc24d Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 16 Dec 2025 14:12:30 -0800 Subject: [PATCH 10/12] SELF-1670: Hide document-only settings without documents (#1512) * Hide document-only settings without documents * revert document info screen changes, rely on hiding option from settings view * agent feedback * hide settings options that depend on having a document --- app/README.md | 2 +- .../account/settings/SettingsScreen.tsx | 48 +++++++++++++++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/app/README.md b/app/README.md index 9ec0dca8f..e6e8c6cc8 100644 --- a/app/README.md +++ b/app/README.md @@ -1,4 +1,4 @@ -# OpenPassport App +# Self.xyz Mobile App ## Requirements diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx index ab415fa97..b8e76452d 100644 --- a/app/src/screens/account/settings/SettingsScreen.tsx +++ b/app/src/screens/account/settings/SettingsScreen.tsx @@ -3,14 +3,14 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import type { PropsWithChildren } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Linking, Platform, Share, View as RNView } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { getCountry, getLocales, getTimeZone } from 'react-native-localize'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { SvgProps } from 'react-native-svg'; import { Button, ScrollView, View, XStack, YStack } from 'tamagui'; -import { useNavigation } from '@react-navigation/native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Bug, FileText } from '@tamagui/lucide-icons'; @@ -44,6 +44,7 @@ import { xUrl, } from '@/consts/links'; import { impactLight } from '@/integrations/haptics'; +import { usePassport } from '@/providers/passportDataProvider'; import { useSettingStore } from '@/stores/settingStore'; import { extraYPadding } from '@/utils/styleUtils'; @@ -103,6 +104,12 @@ const DEBUG_MENU: [React.FC, string, RouteOption][] = [ [Bug as React.FC, 'Debug menu', 'DevSettings'], ]; +const DOCUMENT_DEPENDENT_ROUTES: RouteOption[] = [ + 'CloudBackupSettings', + 'DocumentDataInfo', + 'ShowRecoveryPhrase', +]; + const social = [ [X, xUrl], [Github, gitHubUrl], @@ -152,10 +159,43 @@ const SettingsScreen: React.FC = () => { const { isDevMode, setDevModeOn } = useSettingStore(); const navigation = useNavigation>(); + const { loadDocumentCatalog } = usePassport(); + const [hasRealDocument, setHasRealDocument] = useState(null); + + const refreshDocumentAvailability = useCallback(async () => { + try { + const catalog = await loadDocumentCatalog(); + if (!catalog?.documents || !Array.isArray(catalog.documents)) { + console.warn('SettingsScreen: invalid catalog structure'); + setHasRealDocument(false); + return; + } + setHasRealDocument(catalog.documents.some(doc => !doc.mock)); + } catch { + console.warn('SettingsScreen: failed to load document catalog'); + setHasRealDocument(false); + } + }, [loadDocumentCatalog]); + + useFocusEffect( + useCallback(() => { + refreshDocumentAvailability(); + }, [refreshDocumentAvailability]), + ); const screenRoutes = useMemo(() => { - return isDevMode ? [...routes, ...DEBUG_MENU] : routes; - }, [isDevMode]); + const baseRoutes = isDevMode ? [...routes, ...DEBUG_MENU] : routes; + + // Show all routes while loading or if user has a real document + if (hasRealDocument === null || hasRealDocument === true) { + return baseRoutes; + } + + // Only filter out document-related routes if we've confirmed user has no real documents + return baseRoutes.filter( + ([, , route]) => !DOCUMENT_DEPENDENT_ROUTES.includes(route), + ); + }, [hasRealDocument, isDevMode]); const devModeTap = Gesture.Tap() .numberOfTaps(5) From a6194665ec589a25227537172d647b892b99c65e Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 16 Dec 2025 14:43:45 -0800 Subject: [PATCH 11/12] SELF-1572: Update document scan prompt (#1511) * Update document scan prompt * formatting * fix scan instruction location * use helper for title text --- .../scanning/DocumentCameraScreen.tsx | 15 +- .../selection/DocumentOnboardingScreen.tsx | 10 +- app/src/utils/documentAttributes.ts | 160 +++++++++++------- .../flows/onboarding/id-selection-screen.tsx | 2 + 4 files changed, 119 insertions(+), 68 deletions(-) diff --git a/app/src/screens/documents/scanning/DocumentCameraScreen.tsx b/app/src/screens/documents/scanning/DocumentCameraScreen.tsx index 3a4c61ea9..11115061a 100644 --- a/app/src/screens/documents/scanning/DocumentCameraScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentCameraScreen.tsx @@ -7,7 +7,11 @@ import { StyleSheet } from 'react-native'; import { View, XStack, YStack } from 'tamagui'; import { useIsFocused } from '@react-navigation/native'; -import { DelayedLottieView, dinot } from '@selfxyz/mobile-sdk-alpha'; +import { + DelayedLottieView, + dinot, + useSelfClient, +} from '@selfxyz/mobile-sdk-alpha'; import { Additional, Description, @@ -31,14 +35,21 @@ import Scan from '@/assets/icons/passport_camera_scan.svg'; import { PassportCamera } from '@/components/native/PassportCamera'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; +import { getDocumentScanPrompt } from '@/utils/documentAttributes'; const DocumentCameraScreen: React.FC = () => { const isFocused = useIsFocused(); + const selfClient = useSelfClient(); + const selectedDocumentType = selfClient.useMRZStore( + state => state.documentType, + ); // Add a ref to track when the camera screen is mounted const scanStartTimeRef = useRef(Date.now()); const { onPassportRead } = useReadMRZ(scanStartTimeRef); + const scanPrompt = getDocumentScanPrompt(selectedDocumentType); + const navigateToHome = useHapticNavigation('Home', { action: 'cancel', }); @@ -63,7 +74,7 @@ const DocumentCameraScreen: React.FC = () => { - Scan your ID + {scanPrompt} diff --git a/app/src/screens/documents/selection/DocumentOnboardingScreen.tsx b/app/src/screens/documents/selection/DocumentOnboardingScreen.tsx index 82b8a8588..e1cfbf208 100644 --- a/app/src/screens/documents/selection/DocumentOnboardingScreen.tsx +++ b/app/src/screens/documents/selection/DocumentOnboardingScreen.tsx @@ -8,6 +8,7 @@ import { StyleSheet } from 'react-native'; import { SystemBars } from 'react-native-edge-to-edge'; import { useNavigation } from '@react-navigation/native'; +import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { Additional, ButtonsContainer, @@ -28,12 +29,19 @@ import passportOnboardingAnimation from '@/assets/animations/passport_onboarding import useHapticNavigation from '@/hooks/useHapticNavigation'; import { impactLight } from '@/integrations/haptics'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; +import { getDocumentScanPrompt } from '@/utils/documentAttributes'; const DocumentOnboardingScreen: React.FC = () => { const navigation = useNavigation(); + const selfClient = useSelfClient(); + const selectedDocumentType = selfClient.useMRZStore( + state => state.documentType, + ); const handleCameraPress = useHapticNavigation('DocumentCamera'); const animationRef = useRef(null); + const scanPrompt = getDocumentScanPrompt(selectedDocumentType); + const onCancelPress = () => { impactLight(); navigation.goBack(); @@ -69,7 +77,7 @@ const DocumentOnboardingScreen: React.FC = () => { - Scan your ID + {scanPrompt} Open to the photo page diff --git a/app/src/utils/documentAttributes.ts b/app/src/utils/documentAttributes.ts index ad73e9c4d..4b7f47a96 100644 --- a/app/src/utils/documentAttributes.ts +++ b/app/src/utils/documentAttributes.ts @@ -10,6 +10,11 @@ import { import type { PassportData } from '@selfxyz/common/types/passport'; import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types'; +/** + * Gets the scan prompt for a document type. + * @param documentType - Document type code ('p' = Passport, 'i' = ID card, 'a' = Aadhaar) + * @returns Scan prompt text + */ export interface DocumentAttributes { nameSlice: string; dobSlice: string; @@ -22,6 +27,71 @@ export interface DocumentAttributes { isPassportType: boolean; } +/** + * Checks if a document expiration date (in YYMMDD format) has passed. + * we assume dateOfExpiry is this century because ICAO standard for biometric passport + * became standard around 2002 + * @param dateOfExpiry - Expiration date in YYMMDD format from MRZ + * @returns true if the document is expired, false otherwise + */ +export function checkDocumentExpiration(dateOfExpiry: string): boolean { + if (!dateOfExpiry || dateOfExpiry.length !== 6) { + return false; // Invalid format, don't treat as expired + } + + const year = parseInt(dateOfExpiry.slice(0, 2), 10); + const fullyear = 2000 + year; + const month = parseInt(dateOfExpiry.slice(2, 4), 10) - 1; // JS months are 0-indexed + const day = parseInt(dateOfExpiry.slice(4, 6), 10); + + const expiryDateUTC = new Date(Date.UTC(fullyear, month, day, 0, 0, 0, 0)); + const nowUTC = new Date(); + const todayUTC = new Date( + Date.UTC( + nowUTC.getFullYear(), + nowUTC.getMonth(), + nowUTC.getDate(), + 0, + 0, + 0, + 0, + ), + ); + + return todayUTC >= expiryDateUTC; +} + +/** + * Formats date from YYMMDD format to DD/MM/YYYY format + * For expiry (isDOB is false), we assume its this century because ICAO standard for biometric passport + * became standard around 2002 + */ +export function formatDateFromYYMMDD( + dateString: string, + isDOB: boolean = false, +): string { + if (dateString.length !== 6) { + return dateString; + } + + const yy = parseInt(dateString.substring(0, 2), 10); + const mm = dateString.substring(2, 4); + const dd = dateString.substring(4, 6); + + const currentYear = new Date().getFullYear(); + const century = Math.floor(currentYear / 100) * 100; + let year = century + yy; + + if (isDOB) { + // For birth: if year is in the future, assume previous century + if (year > currentYear) { + year -= 100; + } + } + + return `${dd}/${mm}/${year}`; +} + /** * Extracts attributes from Aadhaar document data */ @@ -119,71 +189,6 @@ function getPassportAttributes( }; } -/** - * Checks if a document expiration date (in YYMMDD format) has passed. - * we assume dateOfExpiry is this century because ICAO standard for biometric passport - * became standard around 2002 - * @param dateOfExpiry - Expiration date in YYMMDD format from MRZ - * @returns true if the document is expired, false otherwise - */ -export function checkDocumentExpiration(dateOfExpiry: string): boolean { - if (!dateOfExpiry || dateOfExpiry.length !== 6) { - return false; // Invalid format, don't treat as expired - } - - const year = parseInt(dateOfExpiry.slice(0, 2), 10); - const fullyear = 2000 + year; - const month = parseInt(dateOfExpiry.slice(2, 4), 10) - 1; // JS months are 0-indexed - const day = parseInt(dateOfExpiry.slice(4, 6), 10); - - const expiryDateUTC = new Date(Date.UTC(fullyear, month, day, 0, 0, 0, 0)); - const nowUTC = new Date(); - const todayUTC = new Date( - Date.UTC( - nowUTC.getFullYear(), - nowUTC.getMonth(), - nowUTC.getDate(), - 0, - 0, - 0, - 0, - ), - ); - - return todayUTC >= expiryDateUTC; -} - -/** - * Formats date from YYMMDD format to DD/MM/YYYY format - * For expiry (isDOB is false), we assume its this century because ICAO standard for biometric passport - * became standard around 2002 - */ -export function formatDateFromYYMMDD( - dateString: string, - isDOB: boolean = false, -): string { - if (dateString.length !== 6) { - return dateString; - } - - const yy = parseInt(dateString.substring(0, 2), 10); - const mm = dateString.substring(2, 4); - const dd = dateString.substring(4, 6); - - const currentYear = new Date().getFullYear(); - const century = Math.floor(currentYear / 100) * 100; - let year = century + yy; - - if (isDOB) { - // For birth: if year is in the future, assume previous century - if (year > currentYear) { - year -= 100; - } - } - - return `${dd}/${mm}/${year}`; -} - // Helper functions to safely extract document data export function getDocumentAttributes( document: PassportData | AadhaarData, @@ -208,6 +213,31 @@ export function getDocumentAttributes( } } +/** + * Gets the display name for a document type code. + * @param documentType - Document type code ('p' = Passport, 'i' = ID card, 'a' = Aadhaar) + * @returns Human-readable document name + */ +export function getDocumentScanPrompt( + documentType: string | undefined, +): string { + const documentName = getDocumentTypeName(documentType); + return `Scan your ${documentName}`; +} + +export function getDocumentTypeName(documentType: string | undefined): string { + switch (documentType) { + case 'p': + return 'Passport'; + case 'i': + return 'ID'; + case 'a': + return 'Aadhaar'; + default: + return 'ID'; + } +} + /** * Parses name from MRZ format (surname< = props => { const onSelectDocumentType = (docType: string) => { buttonTap(); + selfClient.getMRZState().update({ documentType: docType }); + selfClient.emit(SdkEvents.DOCUMENT_TYPE_SELECTED, { documentType: docType, documentName: getDocumentNameForEvent(docType), From e1c7ecdbb863c5ef29d84165656354466eb344a5 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 16 Dec 2025 20:43:14 -0800 Subject: [PATCH 12/12] SELF-1662: Improve prove deeplink navigation (#1517) * Improve prove deeplink navigation * fix tests --- app/src/navigation/deeplinks.ts | 8 ++++++-- app/src/navigation/verification.ts | 2 ++ app/tests/src/navigation/deeplinks.test.ts | 10 ++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/src/navigation/deeplinks.ts b/app/src/navigation/deeplinks.ts index 72ee1db3f..dc1cac680 100644 --- a/app/src/navigation/deeplinks.ts +++ b/app/src/navigation/deeplinks.ts @@ -125,7 +125,9 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().setSelfApp(selfAppJson); selfClient.getSelfAppState().startAppListener(selfAppJson.sessionId); - navigationRef.navigate('Prove' as never); + navigationRef.reset( + createDeeplinkNavigationState('Prove', correctParentScreen), + ); return; } catch (error) { @@ -140,7 +142,9 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().cleanSelfApp(); selfClient.getSelfAppState().startAppListener(sessionId); - navigationRef.navigate('Prove' as never); + navigationRef.reset( + createDeeplinkNavigationState('Prove', correctParentScreen), + ); } else if (mock_passport) { try { const data = JSON.parse(mock_passport); diff --git a/app/src/navigation/verification.ts b/app/src/navigation/verification.ts index 87209a5e0..24492bdfc 100644 --- a/app/src/navigation/verification.ts +++ b/app/src/navigation/verification.ts @@ -17,6 +17,7 @@ const verificationScreens = { options: { headerShown: false, animation: 'slide_from_bottom', + gestureEnabled: false, } as NativeStackNavigationOptions, }, Prove: { @@ -29,6 +30,7 @@ const verificationScreens = { headerTitleStyle: { color: white, }, + gestureEnabled: false, } as NativeStackNavigationOptions, }, QRCodeTrouble: { diff --git a/app/tests/src/navigation/deeplinks.test.ts b/app/tests/src/navigation/deeplinks.test.ts index b22526d08..1c97a10b3 100644 --- a/app/tests/src/navigation/deeplinks.test.ts +++ b/app/tests/src/navigation/deeplinks.test.ts @@ -90,7 +90,10 @@ describe('deeplinks', () => { expect(mockStartAppListener).toHaveBeenCalledWith('abc'); const { navigationRef } = require('@/navigation'); - expect(navigationRef.navigate).toHaveBeenCalledWith('Prove'); + expect(navigationRef.reset).toHaveBeenCalledWith({ + index: 1, + routes: [{ name: 'Home' }, { name: 'Prove' }], + }); }); it('handles sessionId parameter', () => { @@ -113,7 +116,10 @@ describe('deeplinks', () => { expect(mockStartAppListener).toHaveBeenCalledWith('123'); const { navigationRef } = require('@/navigation'); - expect(navigationRef.navigate).toHaveBeenCalledWith('Prove'); + expect(navigationRef.reset).toHaveBeenCalledWith({ + index: 1, + routes: [{ name: 'Home' }, { name: 'Prove' }], + }); }); it('handles mock_passport parameter', () => {