Merge pull request #1516 from selfxyz/release/staging-2025-12-16

Release to Staging - 2025-12-16
This commit is contained in:
Justin Hernandez
2025-12-17 07:26:57 -08:00
committed by GitHub
26 changed files with 479 additions and 231 deletions

View File

@@ -1,4 +1,4 @@
# OpenPassport App
# Self.xyz Mobile App
## Requirements

View File

@@ -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",

View File

@@ -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 = `<svg width="47" height="46" viewBox="0 0 47 46" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -332,6 +333,7 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
name="DOB"
value={formatDateFromYYMMDD(
getDocumentAttributes(idDocument).dobSlice,
true,
)}
maskValue="XX/XX/XXXX"
hidden={hidden}
@@ -342,7 +344,6 @@ const IdCardLayout: FC<IdCardLayoutAttributes> = ({
name="EXPIRY DATE"
value={formatDateFromYYMMDD(
getDocumentAttributes(idDocument).expiryDateSlice,
true,
)}
maskValue="XX/XX/XXXX"
hidden={hidden}
@@ -520,164 +521,3 @@ const IdAttribute: FC<IdAttributeProps> = ({
};
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(/</g, '').trim();
const namesString = parts[1];
// Split names by single < and filter out empty strings
const names = namesString.split('<').filter(name => 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}`;
}

View File

@@ -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'],
}),

View File

@@ -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),
};
}

View File

@@ -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);

View File

@@ -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: {
@@ -44,6 +46,7 @@ const verificationScreens = {
options: {
headerShown: false,
animation: 'slide_from_bottom',
gestureEnabled: false,
} as NativeStackNavigationOptions,
},
};

View File

@@ -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();
}

View File

@@ -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<SvgProps>, string, RouteOption][] = [
[Bug as React.FC<SvgProps>, '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<NativeStackNavigationProp<MinimalRootStackParamList>>();
const { loadDocumentCatalog } = usePassport();
const [hasRealDocument, setHasRealDocument] = useState<boolean | null>(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)

View File

@@ -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';

View File

@@ -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,
@@ -99,7 +95,7 @@ const AadhaarUploadErrorScreen: React.FC = () => {
Try Again
</PrimaryButton>
</YStack>
<YStack flex={1}>
{/* <YStack flex={1}>
<SecondaryButton
onPress={() => {
trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType });
@@ -108,7 +104,7 @@ const AadhaarUploadErrorScreen: React.FC = () => {
>
Need Help?
</SecondaryButton>
</YStack>
</YStack> */}
</XStack>
</YStack>
</YStack>

View File

@@ -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 = () => {
<ExpandableBottomLayout.BottomSection backgroundColor={white}>
<YStack alignItems="center" gap="$2.5">
<YStack alignItems="center" gap="$6" paddingBottom="$2.5">
<Title>Scan your ID</Title>
<Title>{scanPrompt}</Title>
<XStack gap="$6" alignSelf="flex-start" alignItems="flex-start">
<View paddingTop="$2">
<Scan height={40} width={40} color={slate800} />

View File

@@ -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;
};

View File

@@ -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<LottieView>(null);
const scanPrompt = getDocumentScanPrompt(selectedDocumentType);
const onCancelPress = () => {
impactLight();
navigation.goBack();
@@ -69,7 +77,7 @@ const DocumentOnboardingScreen: React.FC = () => {
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection backgroundColor={white}>
<TextsContainer>
<Title>Scan your ID</Title>
<Title>{scanPrompt}</Title>
<Description textBreakStrategy="balanced">
Open to the photo page
</Description>

View File

@@ -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<ScrollView>(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}
/>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>

View File

@@ -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<NativeStackNavigationProp<RootStackParamList>>();
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<QRCodeScannerViewProps['onQRData']>(
async (error, uri) => {
if (doneScanningQR) {
@@ -129,6 +138,23 @@ const QRCodeViewFinderScreen: React.FC = () => {
<>
<ExpandableBottomLayout.Layout backgroundColor={white}>
<ExpandableBottomLayout.TopSection roundTop backgroundColor={black}>
<NavBar.Container
paddingTop={safeAreaTop}
paddingHorizontal="$4"
paddingBottom="$2"
position="absolute"
top={0}
left={0}
right={0}
backgroundColor="transparent"
zIndex={1}
>
<NavBar.LeftAction
component="back"
color={white}
onPress={handleGoBack}
/>
</NavBar.Container>
{shouldRenderCamera && (
<>
<QRCodeScannerView onQRData={onQRData} isMounted={isFocused} />

View File

@@ -0,0 +1,266 @@
// 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';
/**
* 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;
yobSlice: string;
issuingStateSlice: string;
nationalitySlice: string;
passNoSlice: string;
sexSlice: string;
expiryDateSlice: string;
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
*/
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,
};
}
// 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,
};
}
}
/**
* 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<<given names)
* Returns separated surname and names arrays
*/
export function getNameAndSurname(nameSlice: string): {
surname: string[];
names: 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(/</g, '').trim();
const namesString = parts[1];
// Split names by single < and filter out empty strings
const names = namesString.split('<').filter(name => name.length > 0);
return {
surname: surname ? [surname] : [],
names: names[0] ? [names[0]] : [],
};
}

View File

@@ -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', () => {

View File

@@ -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"
}
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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",

View File

@@ -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<HeldPrimaryButtonProveScreen
selectedAppSessionId,
hasScrolledToBottom,
isReadyToProve,
isDocumentExpired,
}) => {
const [state, send] = useMachine(buttonMachine, {
input: { onVerify },
@@ -201,12 +208,16 @@ export const HeldPrimaryButtonProveScreen: React.FC<HeldPrimaryButtonProveScreen
selectedAppSessionId,
hasScrolledToBottom,
isReadyToProve,
isDocumentExpired,
});
}, [selectedAppSessionId, hasScrolledToBottom, isReadyToProve, send]);
}, [selectedAppSessionId, hasScrolledToBottom, isReadyToProve, isDocumentExpired, send]);
const isDisabled = !state.matches('ready');
const renderButtonContent = () => {
if (isDocumentExpired) {
return 'Document expired';
}
if (state.matches('waitingForSession')) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>

View File

@@ -74,6 +74,7 @@ const CountryPickerScreen: React.FC<SafeArea> & { statusBar: typeof CountryPicke
onClose={selfClient.goBack}
onInfoPress={() => selfClient.trackEvent(DocumentEvents.COUNTRY_HELP_TAPPED)}
onSearchChange={onSearchChange}
showInfoIcon={false}
/>
);
};

View File

@@ -80,6 +80,8 @@ const IDSelectionScreen: React.FC<IDSelectionScreenProps> = props => {
const onSelectDocumentType = (docType: string) => {
buttonTap();
selfClient.getMRZState().update({ documentType: docType });
selfClient.emit(SdkEvents.DOCUMENT_TYPE_SELECTED, {
documentType: docType,
documentName: getDocumentNameForEvent(docType),

View File

@@ -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"