From 71674e1265a46a17365918cfc28e7ddfaaefc9a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:50:07 -0800 Subject: [PATCH 01/47] chore: bump mobile app version to 2.9.7 (#1535) 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 0619495d8..1993f8067 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 197, - "lastDeployed": "2025-12-25T18:27:37.342Z" + "build": 198, + "lastDeployed": "2025-12-26T21:34:23.793Z" }, "android": { - "build": 127, - "lastDeployed": "2025-12-17T16:13:30.256Z" + "build": 128, + "lastDeployed": "2025-12-26T21:34:23.793Z" } } From 9860020cd16af143fc586fad78db62641a05cff4 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 30 Dec 2025 12:11:12 -0800 Subject: [PATCH 02/47] typo (#1522) --- app/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/README.md b/app/README.md index e6e8c6cc8..443f8cbf8 100644 --- a/app/README.md +++ b/app/README.md @@ -19,7 +19,7 @@ | Android SDK | Latest | See instructions for Android below | | Android NDK | 27.0.12077973 | See instructions for Android below | -\* To facilitate the installation of the SDK and the NDK, and to pair with development devices with a conventient QR code, you can use Android Studio. +\* To facilitate the installation of the SDK and the NDK, and to pair with development devices with a convenient QR code, you can use Android Studio. ### iOS From 5cdf345372bf0240c2f636cafeec2af3c087efab Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 30 Dec 2025 14:04:28 -0800 Subject: [PATCH 03/47] SELF-773: standardize analytics imports (#1538) * standardize analytics * format --- app/src/components/navbar/Points.tsx | 3 +- app/src/hooks/useConnectionModal.ts | 4 +- app/src/navigation/index.tsx | 3 +- app/src/providers/authProvider.tsx | 4 +- app/src/providers/authProvider.web.tsx | 4 +- .../notificationTrackingProvider.tsx | 4 +- app/src/providers/selfClientProvider.tsx | 10 +- app/src/proving/validateDocument.ts | 4 +- app/src/services/analytics.ts | 210 ++++++++++-------- app/tests/src/navigation.test.tsx | 3 + .../src/proving/validateDocument.test.ts | 26 ++- app/tests/src/services/analytics.test.ts | 4 +- 12 files changed, 147 insertions(+), 132 deletions(-) diff --git a/app/src/components/navbar/Points.tsx b/app/src/components/navbar/Points.tsx index c4f78348a..5b71bbc2f 100644 --- a/app/src/components/navbar/Points.tsx +++ b/app/src/components/navbar/Points.tsx @@ -33,7 +33,7 @@ import { appsUrl } from '@/consts/links'; import { useIncomingPoints, usePoints } from '@/hooks/usePoints'; import { usePointsGuardrail } from '@/hooks/usePointsGuardrail'; import type { RootStackParamList } from '@/navigation'; -import analytics from '@/services/analytics'; +import { trackScreenView } from '@/services/analytics'; import { isTopicSubscribed, requestNotificationPermission, @@ -70,7 +70,6 @@ const Points: React.FC = () => { // Track NavBar view analytics useFocusEffect( React.useCallback(() => { - const { trackScreenView } = analytics(); trackScreenView('Points NavBar', { screenName: 'Points NavBar', }); diff --git a/app/src/hooks/useConnectionModal.ts b/app/src/hooks/useConnectionModal.ts index 31189c547..75c808a8f 100644 --- a/app/src/hooks/useConnectionModal.ts +++ b/app/src/hooks/useConnectionModal.ts @@ -11,11 +11,9 @@ import { apiPingUrl } from '@/consts/links'; import { useModal } from '@/hooks/useModal'; import { useNetInfo } from '@/hooks/useNetInfo'; import { navigationRef } from '@/navigation'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; -const { trackEvent } = analytics(); - const connectionModalParams = { titleText: 'Internet connection error', bodyText: 'In order to use SELF, you must have access to the internet.', diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index 452917e4f..b481036cb 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -30,7 +30,7 @@ import sharedScreens from '@/navigation/shared'; import verificationScreens from '@/navigation/verification'; import type { ModalNavigationParams } from '@/screens/app/ModalScreen'; import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen'; -import analytics from '@/services/analytics'; +import { trackScreenView } from '@/services/analytics'; import type { ProofHistory } from '@/stores/proofTypes'; export const navigationScreens = { @@ -195,7 +195,6 @@ declare global { } } -const { trackScreenView } = analytics(); const Navigation = createStaticNavigation(AppNavigation); const NavigationWithTracking = () => { diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 72a5db305..40be9fb47 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -22,12 +22,10 @@ import { createKeychainOptions, detectSecurityCapabilities, } from '@/integrations/keychain'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; import type { Mnemonic } from '@/types/mnemonic'; -const { trackEvent } = analytics(); - const SERVICE_NAME = 'secret'; type SignedPayload = { signature: string; data: T }; diff --git a/app/src/providers/authProvider.web.tsx b/app/src/providers/authProvider.web.tsx index 6c19f8650..34004a220 100644 --- a/app/src/providers/authProvider.web.tsx +++ b/app/src/providers/authProvider.web.tsx @@ -18,11 +18,9 @@ import React, { import { AuthEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import type { Mnemonic } from '@/types/mnemonic'; -const { trackEvent } = analytics(); - type SignedPayload = { signature: string; data: T }; // Check if Android bridge is available diff --git a/app/src/providers/notificationTrackingProvider.tsx b/app/src/providers/notificationTrackingProvider.tsx index 8746fb55f..a4346c8e1 100644 --- a/app/src/providers/notificationTrackingProvider.tsx +++ b/app/src/providers/notificationTrackingProvider.tsx @@ -8,9 +8,7 @@ import messaging from '@react-native-firebase/messaging'; import { NotificationEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import analytics from '@/services/analytics'; - -const { trackEvent } = analytics(); +import { trackEvent } from '@/services/analytics'; export const NotificationTrackingProvider: React.FC = ({ children, diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index 34cf31c67..40a3f3dcd 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -25,7 +25,7 @@ import type { RootStackParamList } from '@/navigation'; import { navigationRef } from '@/navigation'; import { unsafe_getPrivateKey } from '@/providers/authProvider'; import { selfClientDocumentsAdapter } from '@/providers/passportDataProvider'; -import analytics, { trackNfcEvent } from '@/services/analytics'; +import { trackEvent, trackNfcEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; type GlobalCrypto = { crypto?: { subtle?: Crypto['subtle'] } }; @@ -130,7 +130,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { }, analytics: { trackEvent: (event: string, data?: TrackEventParams) => { - analytics().trackEvent(event, data); + trackEvent(event, data); }, trackNfcEvent: (name: string, data?: Record) => { trackNfcEvent(name, data); @@ -211,21 +211,21 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { if (fcmToken) { try { - analytics().trackEvent('DEVICE_TOKEN_REG_STARTED'); + trackEvent('DEVICE_TOKEN_REG_STARTED'); logProofEvent('info', 'Device token registration started', context); const { registerDeviceToken: registerFirebaseDeviceToken } = await import('@/services/notifications/notificationService'); await registerFirebaseDeviceToken(uuid, fcmToken, isMock); - analytics().trackEvent('DEVICE_TOKEN_REG_SUCCESS'); + trackEvent('DEVICE_TOKEN_REG_SUCCESS'); logProofEvent('info', 'Device token registration success', context); } catch (error) { logProofEvent('warn', 'Device token registration failed', context, { error: error instanceof Error ? error.message : String(error), }); console.error('Error registering device token:', error); - analytics().trackEvent('DEVICE_TOKEN_REG_FAILED', { + trackEvent('DEVICE_TOKEN_REG_FAILED', { message: error instanceof Error ? error.message : String(error), }); } diff --git a/app/src/proving/validateDocument.ts b/app/src/proving/validateDocument.ts index 180c73d08..8549f8d97 100644 --- a/app/src/proving/validateDocument.ts +++ b/app/src/proving/validateDocument.ts @@ -27,9 +27,7 @@ import { storePassportData, updateDocumentRegistrationState, } from '@/providers/passportDataProvider'; -import analytics from '@/services/analytics'; - -const { trackEvent } = analytics(); +import { trackEvent } from '@/services/analytics'; /** * This function checks and updates registration states for all documents and updates the `isRegistered`. diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 3ad9c7415..9bb65e99c 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -93,64 +93,41 @@ function validateParams( return cleanParams(validatedProps); } -/* - Records analytics events and screen views - In development mode, events are logged to console instead of being sent to Segment +/** + * Internal tracking function used by trackEvent and trackScreenView + * Records analytics events and screen views + * In development mode, events are logged to console instead of being sent to Segment */ -const analytics = () => { - function _track( - type: 'event' | 'screen', - eventName: string, - properties?: Record, - ) { - // Validate and clean properties - const validatedProps = validateParams(properties); +function _track( + type: 'event' | 'screen', + eventName: string, + properties?: Record, +) { + // Validate and clean properties + const validatedProps = validateParams(properties); - if (__DEV__) { - console.log(`[DEV: Analytics ${type.toUpperCase()}]`, { - name: eventName, - properties: validatedProps, - }); - return; - } - - if (!segmentClient) { - return; - } - const trackMethod = (e: string, p?: JsonMap) => - type === 'screen' - ? segmentClient.screen(e, p) - : segmentClient.track(e, p); - - if (!validatedProps) { - // you may need to remove the catch when debugging - return trackMethod(eventName).catch(console.info); - } - - // you may need to remove the catch when debugging - trackMethod(eventName, validatedProps).catch(console.info); + if (__DEV__) { + console.log(`[DEV: Analytics ${type.toUpperCase()}]`, { + name: eventName, + properties: validatedProps, + }); + return; } - return { - // Using LiteralCheck will allow constants but not plain string literals - trackEvent: (eventName: string, properties?: TrackEventParams) => { - _track('event', eventName, properties); - }, - trackScreenView: ( - screenName: string, - properties?: Record, - ) => { - _track('screen', screenName, properties); - }, - flush: () => { - if (!__DEV__ && segmentClient) { - segmentClient.flush(); - } - }, - }; -}; + if (!segmentClient) { + return; + } + const trackMethod = (e: string, p?: JsonMap) => + type === 'screen' ? segmentClient.screen(e, p) : segmentClient.track(e, p); -export default analytics; + if (!validatedProps) { + // you may need to remove the catch when debugging + return trackMethod(eventName).catch(console.info); + } + + // you may need to remove the catch when debugging + trackMethod(eventName, validatedProps).catch(console.info); +} /** * Cleanup function to clear event queues @@ -160,6 +137,69 @@ export const cleanupAnalytics = () => { eventCount = 0; }; +// --- Mixpanel NFC Analytics --- +export const configureNfcAnalytics = async () => { + if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return; + const enableDebugLogs = + String(ENABLE_DEBUG_LOGS ?? '') + .trim() + .toLowerCase() === 'true'; + + // Check if PassportReader and configure method exist (Android doesn't have configure) + if (PassportReader && typeof PassportReader.configure === 'function') { + try { + // iOS configure method only accepts token and enableDebugLogs + // Android doesn't have this method at all + await Promise.resolve( + PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs), + ); + } catch (error) { + console.warn('Failed to configure NFC analytics:', error); + } + } + + setupFlushPolicies(); + mixpanelConfigured = true; +}; + +/** + * Flush any pending analytics events immediately + */ +export const flush = () => { + if (!__DEV__ && segmentClient) { + segmentClient.flush(); + } +}; + +/** + * @deprecated Use named exports (trackEvent, trackScreenView, flush) instead + * Factory function that returns analytics methods + * Kept for backward compatibility + */ +const analytics = () => { + return { + trackEvent, + trackScreenView, + flush, + }; +}; + +export default analytics; + +/** + * Consolidated analytics flush function that flushes both Segment and Mixpanel events + * This should be called when you want to ensure all analytics events are sent immediately + */ +export const flushAllAnalytics = () => { + // Flush Segment analytics + flush(); + + // Never flush Mixpanel during active NFC scanning to prevent interference + if (!isNfcScanningActive) { + flushMixpanelEvents().catch(console.warn); + } +}; + const setupFlushPolicies = () => { AppState.addEventListener('change', (state: AppStateStatus) => { // Never flush during active NFC scanning to prevent interference @@ -221,46 +261,6 @@ const flushMixpanelEvents = async () => { } }; -// --- Mixpanel NFC Analytics --- -export const configureNfcAnalytics = async () => { - if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return; - const enableDebugLogs = - String(ENABLE_DEBUG_LOGS ?? '') - .trim() - .toLowerCase() === 'true'; - - // Check if PassportReader and configure method exist (Android doesn't have configure) - if (PassportReader && typeof PassportReader.configure === 'function') { - try { - // iOS configure method only accepts token and enableDebugLogs - // Android doesn't have this method at all - await Promise.resolve( - PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs), - ); - } catch (error) { - console.warn('Failed to configure NFC analytics:', error); - } - } - - setupFlushPolicies(); - mixpanelConfigured = true; -}; - -/** - * Consolidated analytics flush function that flushes both Segment and Mixpanel events - * This should be called when you want to ensure all analytics events are sent immediately - */ -export const flushAllAnalytics = () => { - // Flush Segment analytics - const { flush: flushAnalytics } = analytics(); - flushAnalytics(); - - // Never flush Mixpanel during active NFC scanning to prevent interference - if (!isNfcScanningActive) { - flushMixpanelEvents().catch(console.warn); - } -}; - /** * Set NFC scanning state to prevent analytics flush interference */ @@ -277,6 +277,18 @@ export const setNfcScanningActive = (active: boolean) => { } }; +/** + * Track an analytics event + * @param eventName - Name of the event to track + * @param properties - Optional properties to attach to the event + */ +export const trackEvent = ( + eventName: string, + properties?: TrackEventParams, +) => { + _track('event', eventName, properties); +}; + export const trackNfcEvent = async ( name: string, properties?: Record, @@ -302,3 +314,15 @@ export const trackNfcEvent = async ( eventQueue.push({ name, properties }); } }; + +/** + * Track a screen view + * @param screenName - Name of the screen to track + * @param properties - Optional properties to attach to the screen view + */ +export const trackScreenView = ( + screenName: string, + properties?: Record, +) => { + _track('screen', screenName, properties); +}; diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 3ed68c962..fbb8e3514 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -19,6 +19,9 @@ jest.mock('@/services/analytics', () => ({ trackScreenView: jest.fn(), flush: jest.fn(), })), + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), })); describe('navigation', () => { diff --git a/app/tests/src/proving/validateDocument.test.ts b/app/tests/src/proving/validateDocument.test.ts index c24c25516..f3fca732d 100644 --- a/app/tests/src/proving/validateDocument.test.ts +++ b/app/tests/src/proving/validateDocument.test.ts @@ -11,16 +11,20 @@ import { checkAndUpdateRegistrationStates, getAlternativeCSCA, } from '@/proving/validateDocument'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; // Mock the analytics module to avoid side effects in tests -jest.mock('@/services/analytics', () => { - // Create mock inside factory to avoid temporal dead zone - const mockTrackEvent = jest.fn(); - return jest.fn(() => ({ - trackEvent: mockTrackEvent, - })); -}); +jest.mock('@/services/analytics', () => ({ + __esModule: true, + default: jest.fn(() => ({ + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), + })), + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), +})); // Mock the passport data provider to avoid database operations const mockGetAllDocumentsDirectlyFromKeychain = jest.fn(); @@ -153,8 +157,7 @@ describe('getAlternativeCSCA', () => { beforeEach(() => { jest.clearAllMocks(); // Get the mocked trackEvent from the analytics module - const mockAnalytics = jest.mocked(analytics); - mockTrackEvent = mockAnalytics().trackEvent as jest.Mock; + mockTrackEvent = jest.mocked(trackEvent) as jest.Mock; }); it('should return public keys in Record format for Aadhaar with valid public keys', () => { @@ -245,8 +248,7 @@ describe('checkAndUpdateRegistrationStates', () => { beforeEach(() => { jest.clearAllMocks(); // Get the mocked trackEvent from the analytics module - const mockAnalytics = jest.mocked(analytics); - mockTrackEvent = mockAnalytics().trackEvent as jest.Mock; + mockTrackEvent = jest.mocked(trackEvent) as jest.Mock; mockGetState.mockReturnValue( buildState({ diff --git a/app/tests/src/services/analytics.test.ts b/app/tests/src/services/analytics.test.ts index 18e13f580..a588b9f58 100644 --- a/app/tests/src/services/analytics.test.ts +++ b/app/tests/src/services/analytics.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import analytics from '@/services/analytics'; +import { trackEvent, trackScreenView } from '@/services/analytics'; // Mock the Segment client jest.mock('@/config/segment', () => ({ @@ -13,8 +13,6 @@ jest.mock('@/config/segment', () => ({ })); describe('analytics', () => { - const { trackEvent, trackScreenView } = analytics(); - beforeEach(() => { jest.clearAllMocks(); }); From 42a74e28870205f8febe643d3b63fcf25171dae2 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 30 Dec 2025 15:21:29 -0800 Subject: [PATCH 04/47] SELF-1155: Address missing screen views (#1539) * use track events for screen views * don't use factory pattern for analytics imports * nice clean up * simplify screen event logic and add tests * fix test and agent feedback --- .../recovery/DocumentDataNotFoundScreen.tsx | 4 +- .../scanning/DocumentCameraTroubleScreen.tsx | 4 +- app/src/screens/shared/ComingSoonScreen.tsx | 4 +- app/src/services/analytics.ts | 180 ++++++++++-------- app/tests/src/services/analytics.test.ts | 116 ++++++++++- 5 files changed, 215 insertions(+), 93 deletions(-) diff --git a/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx b/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx index 017d926e3..45a4a6a20 100644 --- a/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx +++ b/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx @@ -21,9 +21,7 @@ import { import useHapticNavigation from '@/hooks/useHapticNavigation'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; -import analytics from '@/services/analytics'; - -const { flush: flushAnalytics } = analytics(); +import { flush as flushAnalytics } from '@/services/analytics'; const DocumentDataNotFoundScreen: React.FC = () => { const selfClient = useSelfClient(); diff --git a/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx b/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx index 5534819b8..e9d0a2c10 100644 --- a/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx @@ -16,9 +16,7 @@ import type { TipProps } from '@/components/Tips'; import Tips from '@/components/Tips'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout'; -import analytics from '@/services/analytics'; - -const { flush: flushAnalytics } = analytics(); +import { flush as flushAnalytics } from '@/services/analytics'; const tips: TipProps[] = [ { diff --git a/app/src/screens/shared/ComingSoonScreen.tsx b/app/src/screens/shared/ComingSoonScreen.tsx index f3959fa85..2636e06a2 100644 --- a/app/src/screens/shared/ComingSoonScreen.tsx +++ b/app/src/screens/shared/ComingSoonScreen.tsx @@ -25,11 +25,9 @@ import useHapticNavigation from '@/hooks/useHapticNavigation'; import { notificationError } from '@/integrations/haptics'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { SharedRoutesParamList } from '@/navigation/types'; -import analytics from '@/services/analytics'; +import { flush as flushAnalytics } from '@/services/analytics'; import { sendCountrySupportNotification } from '@/services/email'; -const { flush: flushAnalytics } = analytics(); - type ComingSoonScreenProps = NativeStackScreenProps< SharedRoutesParamList, 'ComingSoon' diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 9bb65e99c..86fda5704 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -13,9 +13,19 @@ import type { TrackEventParams } from '@selfxyz/mobile-sdk-alpha'; import { createSegmentClient } from '@/config/segment'; import { PassportReader } from '@/integrations/nfc/passportReader'; +// ============================================================================ +// Constants +// ============================================================================ + +const MIXPANEL_AUTO_FLUSH_THRESHOLD = 5; +const MAX_EVENT_QUEUE_SIZE = 100; + +// ============================================================================ +// State Management +// ============================================================================ + const segmentClient = createSegmentClient(); -// --- Analytics flush strategy --- let mixpanelConfigured = false; let eventCount = 0; let isConnected = true; @@ -25,6 +35,10 @@ const eventQueue: Array<{ properties?: Record; }> = []; +// ============================================================================ +// Internal Helpers - JSON Coercion +// ============================================================================ + function coerceToJsonValue( value: unknown, seen = new WeakSet(), @@ -93,22 +107,31 @@ function validateParams( return cleanParams(validatedProps); } +// ============================================================================ +// Internal Helpers - Event Tracking +// ============================================================================ + /** * Internal tracking function used by trackEvent and trackScreenView * Records analytics events and screen views * In development mode, events are logged to console instead of being sent to Segment + * + * NOTE: Screen views are tracked as 'Screen Viewed' events for Mixpanel compatibility */ function _track( type: 'event' | 'screen', eventName: string, properties?: Record, ) { + // Transform screen events for Mixpanel compatibility + const finalEventName = type === 'screen' ? `Viewed ${eventName}` : eventName; + // Validate and clean properties const validatedProps = validateParams(properties); if (__DEV__) { console.log(`[DEV: Analytics ${type.toUpperCase()}]`, { - name: eventName, + name: finalEventName, properties: validatedProps, }); return; @@ -117,18 +140,21 @@ function _track( if (!segmentClient) { return; } - const trackMethod = (e: string, p?: JsonMap) => - type === 'screen' ? segmentClient.screen(e, p) : segmentClient.track(e, p); + // Always use track() for both events and screen views (Mixpanel compatibility) if (!validatedProps) { // you may need to remove the catch when debugging - return trackMethod(eventName).catch(console.info); + return segmentClient.track(finalEventName).catch(console.info); } // you may need to remove the catch when debugging - trackMethod(eventName, validatedProps).catch(console.info); + segmentClient.track(finalEventName, validatedProps).catch(console.info); } +// ============================================================================ +// Public API - Segment Analytics +// ============================================================================ + /** * Cleanup function to clear event queues */ @@ -137,13 +163,12 @@ export const cleanupAnalytics = () => { eventCount = 0; }; -// --- Mixpanel NFC Analytics --- +// ============================================================================ +// Public API - Mixpanel NFC Analytics +// ============================================================================ export const configureNfcAnalytics = async () => { if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return; - const enableDebugLogs = - String(ENABLE_DEBUG_LOGS ?? '') - .trim() - .toLowerCase() === 'true'; + const enableDebugLogs = ENABLE_DEBUG_LOGS; // Check if PassportReader and configure method exist (Android doesn't have configure) if (PassportReader && typeof PassportReader.configure === 'function') { @@ -171,21 +196,6 @@ export const flush = () => { } }; -/** - * @deprecated Use named exports (trackEvent, trackScreenView, flush) instead - * Factory function that returns analytics methods - * Kept for backward compatibility - */ -const analytics = () => { - return { - trackEvent, - trackScreenView, - flush, - }; -}; - -export default analytics; - /** * Consolidated analytics flush function that flushes both Segment and Mixpanel events * This should be called when you want to ensure all analytics events are sent immediately @@ -200,6 +210,70 @@ export const flushAllAnalytics = () => { } }; +/** + * Set NFC scanning state to prevent analytics flush interference + */ +export const setNfcScanningActive = (active: boolean) => { + isNfcScanningActive = active; + if (__DEV__) + console.log( + `[NFC Analytics] Scanning state: ${active ? 'active' : 'inactive'}`, + ); + + // Flush queued events when scanning completes + if (!active && eventQueue.length > 0) { + flushMixpanelEvents().catch(console.warn); + } +}; + +/** + * Track an analytics event + * @param eventName - Name of the event to track + * @param properties - Optional properties to attach to the event + */ +export const trackEvent = ( + eventName: string, + properties?: TrackEventParams, +) => { + _track('event', eventName, properties); +}; + +export const trackNfcEvent = async ( + name: string, + properties?: Record, +) => { + if (!MIXPANEL_NFC_PROJECT_TOKEN) return; + if (!mixpanelConfigured) await configureNfcAnalytics(); + + if (!isConnected || isNfcScanningActive) { + if (eventQueue.length >= MAX_EVENT_QUEUE_SIZE) { + if (__DEV__) + console.warn('[Mixpanel] Event queue full, dropping oldest event'); + eventQueue.shift(); + } + eventQueue.push({ name, properties }); + return; + } + + try { + if (PassportReader && PassportReader.trackEvent) { + await Promise.resolve(PassportReader.trackEvent(name, properties)); + } + eventCount++; + // Prevent automatic flush during NFC scanning + if (eventCount >= MIXPANEL_AUTO_FLUSH_THRESHOLD && !isNfcScanningActive) { + flushMixpanelEvents().catch(console.warn); + } + } catch { + if (eventQueue.length >= MAX_EVENT_QUEUE_SIZE) { + if (__DEV__) + console.warn('[Mixpanel] Event queue full, dropping oldest event'); + eventQueue.shift(); + } + eventQueue.push({ name, properties }); + } +}; + const setupFlushPolicies = () => { AppState.addEventListener('change', (state: AppStateStatus) => { // Never flush during active NFC scanning to prevent interference @@ -261,60 +335,6 @@ const flushMixpanelEvents = async () => { } }; -/** - * Set NFC scanning state to prevent analytics flush interference - */ -export const setNfcScanningActive = (active: boolean) => { - isNfcScanningActive = active; - if (__DEV__) - console.log( - `[NFC Analytics] Scanning state: ${active ? 'active' : 'inactive'}`, - ); - - // Flush queued events when scanning completes - if (!active && eventQueue.length > 0) { - flushMixpanelEvents().catch(console.warn); - } -}; - -/** - * Track an analytics event - * @param eventName - Name of the event to track - * @param properties - Optional properties to attach to the event - */ -export const trackEvent = ( - eventName: string, - properties?: TrackEventParams, -) => { - _track('event', eventName, properties); -}; - -export const trackNfcEvent = async ( - name: string, - properties?: Record, -) => { - if (!MIXPANEL_NFC_PROJECT_TOKEN) return; - if (!mixpanelConfigured) await configureNfcAnalytics(); - - if (!isConnected || isNfcScanningActive) { - eventQueue.push({ name, properties }); - return; - } - - try { - if (PassportReader && PassportReader.trackEvent) { - await Promise.resolve(PassportReader.trackEvent(name, properties)); - } - eventCount++; - // Prevent automatic flush during NFC scanning - if (eventCount >= 5 && !isNfcScanningActive) { - flushMixpanelEvents().catch(console.warn); - } - } catch { - eventQueue.push({ name, properties }); - } -}; - /** * Track a screen view * @param screenName - Name of the screen to track diff --git a/app/tests/src/services/analytics.test.ts b/app/tests/src/services/analytics.test.ts index a588b9f58..e0d599a8b 100644 --- a/app/tests/src/services/analytics.test.ts +++ b/app/tests/src/services/analytics.test.ts @@ -7,8 +7,8 @@ import { trackEvent, trackScreenView } from '@/services/analytics'; // Mock the Segment client jest.mock('@/config/segment', () => ({ createSegmentClient: jest.fn(() => ({ - track: jest.fn(), - screen: jest.fn(), + track: jest.fn().mockResolvedValue(undefined), + flush: jest.fn().mockResolvedValue(undefined), })), })); @@ -36,7 +36,7 @@ describe('analytics', () => { }); it('should handle event tracking with null properties', () => { - expect(() => trackEvent('test_event', null)).not.toThrow(); + expect(() => trackEvent('test_event', null as any)).not.toThrow(); }); it('should handle event tracking with undefined properties', () => { @@ -85,7 +85,7 @@ describe('analytics', () => { it('should handle invalid duration values gracefully', () => { const properties = { - duration_seconds: 'not_a_number', + duration_seconds: 'not_a_number' as any, }; expect(() => trackEvent('test_event', properties)).not.toThrow(); @@ -142,6 +142,22 @@ describe('analytics', () => { expect(() => trackEvent('test_event', properties)).not.toThrow(); }); + + it('should NOT transform regular event names (only screen views get "Viewed" prefix)', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + trackEvent('user_login', { method: 'google' }); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DEV: Analytics EVENT]', + expect.objectContaining({ + name: 'user_login', // No "Viewed" prefix for regular events + properties: expect.objectContaining({ method: 'google' }), + }), + ); + + consoleSpy.mockRestore(); + }); }); describe('trackScreenView', () => { @@ -174,6 +190,98 @@ describe('analytics', () => { expect(() => trackScreenView('test_screen', properties)).not.toThrow(); }); + + it('should transform screen views to "Viewed ScreenName" format', () => { + // Mock console.log to capture dev mode output + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + trackScreenView('SplashScreen', { user_id: 123 }); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DEV: Analytics SCREEN]', + expect.objectContaining({ + name: 'Viewed SplashScreen', + properties: expect.objectContaining({ user_id: 123 }), + }), + ); + + consoleSpy.mockRestore(); + }); + + it('should transform screen names correctly without properties', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + trackScreenView('DocumentNFCScanScreen'); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DEV: Analytics SCREEN]', + expect.objectContaining({ + name: 'Viewed DocumentNFCScanScreen', + properties: undefined, + }), + ); + + consoleSpy.mockRestore(); + }); + + it('should pass through properties unchanged', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const properties = { + referrer: 'home', + user_id: 456, + navigation_method: 'swipe', + }; + + trackScreenView('SettingsScreen', properties); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DEV: Analytics SCREEN]', + expect.objectContaining({ + name: 'Viewed SettingsScreen', + properties: expect.objectContaining(properties), + }), + ); + + consoleSpy.mockRestore(); + }); + + it('should call segment client with transformed event name in production', () => { + // Temporarily mock __DEV__ to false for production testing + const originalDev = (global as any).__DEV__; + (global as any).__DEV__ = false; + + try { + // Reset modules first to clear the cache + jest.resetModules(); + + // Get the mocked segment client factory after reset + const segmentModule = require('@/config/segment'); + const mockTrack = jest.fn().mockResolvedValue(undefined); + + // Set up the mock implementation before re-requiring analytics + // This ensures the mock is properly configured when analytics module loads + segmentModule.createSegmentClient.mockImplementation(() => ({ + track: mockTrack, + flush: jest.fn().mockResolvedValue(undefined), + })); + + // Now re-require analytics to get a fresh segmentClient instance + // that uses our mocked createSegmentClient + const analyticsModule = require('@/services/analytics'); + + analyticsModule.trackScreenView('HomeScreen', { user_type: 'premium' }); + + expect(mockTrack).toHaveBeenCalledWith('Viewed HomeScreen', { + user_type: 'premium', + }); + } finally { + // Restore original __DEV__ value + (global as any).__DEV__ = originalDev; + + // Reset modules again to restore original state for other tests + jest.resetModules(); + } + }); }); describe('edge cases', () => { From ef131b10f71cd8a7a095fc649520f2ff43ea8cb5 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:01:16 +0530 Subject: [PATCH 05/47] Hotfix/dev setting screen (#1540) * fix: DevSettingScreen::ScreenSeletor in android * revert: remove severity from extended loggers --- app/src/screens/dev/DevSettingsScreen.tsx | 168 ++++++++++++---------- app/src/services/logging/index.ts | 22 --- app/tests/src/services/logging.test.ts | 47 ++---- 3 files changed, 103 insertions(+), 134 deletions(-) diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index c47655a62..09b171929 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -11,8 +11,8 @@ import React, { useState, } from 'react'; import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; -import { Alert, ScrollView } from 'react-native'; -import { Adapt, Button, Select, Sheet, Text, XStack, YStack } from 'tamagui'; +import { Alert, ScrollView, TouchableOpacity } from 'react-native'; +import { Button, Sheet, Text, XStack, YStack } from 'tamagui'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { Check, ChevronDown, ChevronRight } from '@tamagui/lucide-icons'; @@ -187,82 +187,100 @@ function ParameterSection({ const ScreenSelector = ({}) => { const navigation = useNavigation(); const [open, setOpen] = useState(false); + + const screenList = useMemo( + () => + ( + Object.keys(navigationScreens) as (keyof typeof navigationScreens)[] + ).sort(), + [], + ); + return ( - + + + + + + + Select screen + + + + + {screenList.map(item => ( + { + setOpen(false); + navigation.navigate(item as never); + }} + > + + + {item} + + + + ))} + + + + + ); }; diff --git a/app/src/services/logging/index.ts b/app/src/services/logging/index.ts index 5f58a78ee..be2bb07ef 100644 --- a/app/src/services/logging/index.ts +++ b/app/src/services/logging/index.ts @@ -56,33 +56,11 @@ const DocumentLogger = Logger.extend('DOCUMENT'); //Native Modules const NfcLogger = Logger.extend('NFC'); -// Collect all extended loggers for severity updates -const extendedLoggers = [ - AppLogger, - NotificationLogger, - AuthLogger, - PassportLogger, - ProofLogger, - SettingsLogger, - BackupLogger, - MockDataLogger, - DocumentLogger, - NfcLogger, -]; - // Subscribe to settings store changes to update logger severity dynamically -// Extended loggers are independent instances, so we need to update each one -// Note: Dynamically created loggers (e.g., in nativeLoggerBridge for unknown categories) -// will inherit the severity at creation time but won't receive runtime updates let previousSeverity = initialSeverity; useSettingStore.subscribe(state => { if (state.loggingSeverity !== previousSeverity) { Logger.setSeverity(state.loggingSeverity); - // Update all extended loggers since they don't inherit runtime changes - // Extended loggers have setSeverity at runtime, even if not in type definition - extendedLoggers.forEach(extLogger => { - (extLogger as typeof Logger).setSeverity(state.loggingSeverity); - }); previousSeverity = state.loggingSeverity; } }); diff --git a/app/tests/src/services/logging.test.ts b/app/tests/src/services/logging.test.ts index 2c5aa3072..a141bba20 100644 --- a/app/tests/src/services/logging.test.ts +++ b/app/tests/src/services/logging.test.ts @@ -94,10 +94,9 @@ describe('Logging Service - Severity Updates', () => { }); }); - it('should update severity on root logger and all extended loggers when settings change', async () => { + it('should update severity on root logger when settings change', async () => { // Clear any calls from initialization mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); // Change the logging severity in the store useSettingStore.getState().setLoggingSeverity('debug'); @@ -106,30 +105,21 @@ describe('Logging Service - Severity Updates', () => { await new Promise(resolve => setTimeout(resolve, 10)); // Verify root logger was updated + // Extended loggers inherit severity from root logger automatically expect(mockRootSetSeverity).toHaveBeenCalledTimes(1); expect(mockRootSetSeverity).toHaveBeenCalledWith('debug'); - - // Verify each extended logger was updated - mockLoggerInstances.forEach(logger => { - expect(logger.setSeverity).toHaveBeenCalledTimes(1); - expect(logger.setSeverity).toHaveBeenCalledWith('debug'); - }); }); - it('should update each specific extended logger individually', async () => { + it('should update root logger severity which extends to all loggers', async () => { mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); useSettingStore.getState().setLoggingSeverity('info'); await new Promise(resolve => setTimeout(resolve, 10)); - // Verify specific loggers by name - const specificLoggers = ['APP', 'NFC', 'PASSPORT', 'PROOF']; - specificLoggers.forEach(loggerName => { - const logger = mockLoggerInstances.get(loggerName); - expect(logger).toBeDefined(); - expect(logger?.setSeverity).toHaveBeenCalledWith('info'); - }); + // Verify root logger was updated + // Extended loggers (APP, NFC, PASSPORT, PROOF, etc.) inherit from root + expect(mockRootSetSeverity).toHaveBeenCalledTimes(1); + expect(mockRootSetSeverity).toHaveBeenCalledWith('info'); }); it('should update severity for all severity levels', async () => { @@ -142,24 +132,18 @@ describe('Logging Service - Severity Updates', () => { for (const level of severityLevels) { mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); useSettingStore.getState().setLoggingSeverity(level); await new Promise(resolve => setTimeout(resolve, 10)); - // Verify root logger + // Verify root logger was updated + // Extended loggers inherit severity from root automatically expect(mockRootSetSeverity).toHaveBeenCalledWith(level); - - // Verify all extended loggers - mockLoggerInstances.forEach(logger => { - expect(logger.setSeverity).toHaveBeenCalledWith(level); - }); } }); it('should not call setSeverity if severity has not changed', async () => { mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); // Get current severity const currentSeverity = useSettingStore.getState().loggingSeverity; @@ -171,16 +155,10 @@ describe('Logging Service - Severity Updates', () => { // Should not call setSeverity on root logger expect(mockRootSetSeverity).not.toHaveBeenCalled(); - - // Should not call setSeverity on any extended logger - mockLoggerInstances.forEach(logger => { - expect(logger.setSeverity).not.toHaveBeenCalled(); - }); }); it('should handle rapid severity changes correctly', async () => { mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); // Rapidly change severity multiple times useSettingStore.getState().setLoggingSeverity('debug'); @@ -192,15 +170,10 @@ describe('Logging Service - Severity Updates', () => { await new Promise(resolve => setTimeout(resolve, 50)); // Should have been called 4 times (once per change) + // Extended loggers inherit severity from root automatically expect(mockRootSetSeverity).toHaveBeenCalledTimes(4); // The last call should be 'error' expect(mockRootSetSeverity).toHaveBeenLastCalledWith('error'); - - // Each extended logger should also have been called 4 times - mockLoggerInstances.forEach(logger => { - expect(logger.setSeverity).toHaveBeenCalledTimes(4); - expect(logger.setSeverity).toHaveBeenLastCalledWith('error'); - }); }); }); From 2e9b79446aa602a15b5cc25ea47eb8ec79e2f645 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sat, 3 Jan 2026 15:06:10 -0800 Subject: [PATCH 06/47] bump version (#1542) --- app/android/app/build.gradle | 2 +- app/ios/OpenPassport/Info.plist | 2 +- app/ios/Self.xcodeproj/project.pbxproj | 4 ++-- app/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 8767efa4c..a9398ed55 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -135,7 +135,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 121 - versionName "2.9.7" + versionName "2.9.8" manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp'] externalNativeBuild { cmake { diff --git a/app/ios/OpenPassport/Info.plist b/app/ios/OpenPassport/Info.plist index 0aa1dd8db..52e6e6f62 100644 --- a/app/ios/OpenPassport/Info.plist +++ b/app/ios/OpenPassport/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.9.7 + 2.9.8 CFBundleSignature ???? CFBundleURLTypes diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index a3d8833b1..93884b404 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -546,7 +546,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.7; + MARKETING_VERSION = 2.9.8; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -686,7 +686,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.7; + MARKETING_VERSION = 2.9.8; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/package.json b/app/package.json index f4f11bc19..2d837d22d 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@selfxyz/mobile-app", - "version": "2.9.7", + "version": "2.9.8", "private": true, "type": "module", "scripts": { From 5e88703560ad0e7bd09b0c6381346e649f4486d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:04:08 -0800 Subject: [PATCH 07/47] chore: bump mobile app version to 2.9.8 (#1544) 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 1993f8067..49a9dfead 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 198, - "lastDeployed": "2025-12-26T21:34:23.793Z" + "build": 199, + "lastDeployed": "2026-01-03T23:45:02.007Z" }, "android": { - "build": 128, - "lastDeployed": "2025-12-26T21:34:23.793Z" + "build": 129, + "lastDeployed": "2026-01-03T23:45:02.007Z" } } From 753461f4cbaba762d9d550669b0c2e14b24b5edc Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 5 Jan 2026 09:27:14 -0800 Subject: [PATCH 08/47] Create mobile dependency check script and update readme (#1547) * update readme * add check / install script and update readme * agent feedback * gracefully handle yarn check * change order and steps --- app/README.md | 143 ++++++++++++++++++---------- app/scripts/setup-macos.sh | 184 +++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 50 deletions(-) create mode 100755 app/scripts/setup-macos.sh diff --git a/app/README.md b/app/README.md index 443f8cbf8..ecd599a11 100644 --- a/app/README.md +++ b/app/README.md @@ -1,32 +1,80 @@ # Self.xyz Mobile App +## Quick Start + +Run the interactive setup script to check and install all dependencies: + +```bash +./scripts/setup-macos.sh +``` + +The script will prompt you to choose between: +1. **Check only** - Just show what's installed/missing +2. **Interactive setup** - Check and confirm before installing (recommended) +3. **Auto-install** - Install everything without prompts + +You can also pass flags directly: `--check-only` or `--yes` + ## Requirements -| Requirement | Version | Installation Guide | -| ----------- | -------- | ------------------------------------------------------------------------ | -| nodejs | >= 22 | [Install nodejs](https://nodejs.org/) | -| ruby | >= 3.1.0 | [Install ruby](https://www.ruby-lang.org/en/documentation/installation/) | -| circom | Latest | [Install circom](https://docs.circom.io/) | -| snarkjs | Latest | [Install snarkjs](https://github.com/iden3/snarkjs) | -| watchman | Latest | [Install watchman](https://facebook.github.io/watchman/) | +### macOS Setup -### Android +#### Core Dependencies -| Requirement | Version | Installation Guide | -| --------------------------- | ------------- | ------------------------------------------------------------------------------------ | -| Java | 17 | [Install Java](https://www.oracle.com/java/technologies/javase-jdk17-downloads.html) | -| Android Studio (Optional)\* | Latest | [Install Android Studio](https://developer.android.com/studio) | -| Android SDK | Latest | See instructions for Android below | -| Android NDK | 27.0.12077973 | See instructions for Android below | +```bash +# Node.js 22+ (via nvm) +nvm install 22 +nvm use 22 -\* To facilitate the installation of the SDK and the NDK, and to pair with development devices with a convenient QR code, you can use Android Studio. +# Watchman +brew install watchman -### iOS +# Ruby (via rbenv) - version specified in .ruby-version +brew install rbenv +echo 'eval "$(rbenv init -)"' >> ~/.zshrc +source ~/.zshrc +rbenv install # Reads version from .ruby-version +rbenv rehash -| Requirement | Version | Installation Guide | -| ----------- | ------- | --------------------------------------------------- | -| Xcode | Latest | [Install Xcode](https://developer.apple.com/xcode/) | -| cocoapods | Latest | [Install cocoapods](https://cocoapods.org/) | +# Ruby gems +gem install cocoapods bundler + +# circom and snarkjs (for ZK circuits) +# Follow: https://docs.circom.io/ and https://github.com/iden3/snarkjs +``` + +#### Android Dependencies + +```bash +# Java 17 +brew install openjdk@17 +``` + +Then install [Android Studio](https://developer.android.com/studio) and configure SDK/NDK (see [Android Setup](#android) below). + +#### iOS Dependencies + +Install [Xcode](https://developer.apple.com/xcode/) from the App Store (includes Command Line Tools). + +### Shell Configuration + +Add the following to your `~/.zshrc` (or `~/.bashrc`): + +```bash +# Java +export JAVA_HOME=$(/usr/libexec/java_home -v 17) + +# Android +export ANDROID_HOME=~/Library/Android/sdk +export ANDROID_SDK_ROOT=$ANDROID_HOME +export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools +``` + +Then reload your shell: + +```bash +source ~/.zshrc +``` ## Installation @@ -54,47 +102,41 @@ and rerun the command. ### Android -#### Using Android Studio +#### Using Android Studio (Recommended) -In Android Studio, go to **Tools** > **SDK Manager** in the menu +1. Download and install [Android Studio](https://developer.android.com/studio) +2. Open Android Studio → **Settings** (or **Preferences** on macOS) → **SDK Manager** +3. Under **SDK Platforms**, install the platform with the highest API number +4. Under **SDK Tools**, check **Show Package Details**, expand **NDK (Side by side)**, select version **27.0.12077973** and install +5. Enable **USB debugging** on your Android device (Settings → Developer options → USB debugging) -Under **SDK Platforms**, install the platform with the highest API number +#### Using sdkmanager via CLI (Alternative) -Under **SDK Tools**, check the **Show Package Details** checkbox, expand **NDK (Side by side)**, select version **27.0.12077973** and install. +If you prefer not to use Android Studio, you can install the SDK via command line: -#### Using sdkmanager via CLI - -Create a directory for the Android SDK. For example `~/android_sdk`. Define the environment variable `ANDROID_HOME` to point that directory. - -Install sdkmanager under `ANDROID_HOME` according to the instructions on https://developer.android.com/tools/sdkmanager - -List available SDK platforms +1. Create a directory for the Android SDK (e.g., `~/android_sdk`) and set `ANDROID_HOME` to point to it +2. Install sdkmanager according to the [official instructions](https://developer.android.com/tools/sdkmanager) ```bash +# List available SDK platforms $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --list | grep platforms -``` -In the list of platforms, find the latest version and install it. (Replace _NN_ with the latest version number) - -```bash +# Install the latest platform (replace NN with version number) $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "platforms;android-NN" -``` -Install the NDK - -```bash +# Install the NDK $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.12077973" -``` -Define the environment variable `ANDROID_NDK_VERSION` to `27.0.12077973` and `ANDROID_NDK` to `$ANDROID_HOME/ndk/27.0.12077973` - -Install Platform Tools, needed for the `adb` tool - -```bash +# Install Platform Tools (for adb) $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install platform-tools ``` -Add `$ANDROID_HOME/platform-tools` to your `$PATH` variable +Set additional environment variables: + +```bash +export ANDROID_NDK_VERSION=27.0.12077973 +export ANDROID_NDK=$ANDROID_HOME/ndk/27.0.12077973 +``` ## Run the app @@ -149,13 +191,14 @@ To view the Android logs, use the Logcat feature in Android Studio, or use the ` > :warning: To run the app on iOS, you will need a paying Apple Developer account. Free accounts can't run apps that use NFC reading.
> Contact us if you need it to contribute. -Open the ios project on Xcode and add your provisioning profile in Targets > OpenPassport > Signing and Capabilities +Open the ios project in Xcode and add your provisioning profile in Targets > OpenPassport > Signing and Capabilities. -Then, install pods: +Then, install Ruby dependencies and CocoaPods: -``` +```bash cd ios -pod install +bundle install +bundle exec pod install ``` And run the app in Xcode. diff --git a/app/scripts/setup-macos.sh b/app/scripts/setup-macos.sh new file mode 100755 index 000000000..c6b12fa75 --- /dev/null +++ b/app/scripts/setup-macos.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# Self.xyz macOS Development Environment Setup +# Usage: ./scripts/setup-macos.sh [--check-only] [--yes] + +set -e + +# Config +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +APP_DIR="$REPO_ROOT/app" +RUBY_VERSION=$(cat "$APP_DIR/.ruby-version" 2>/dev/null | tr -d '[:space:]') +NODE_MAJOR=22 + +# Args (can be overridden interactively) +CHECK_ONLY=false; AUTO_YES=false +for arg in "$@"; do + case $arg in + --check-only) CHECK_ONLY=true ;; + --yes|-y) AUTO_YES=true ;; + --interactive|-i) ;; # default behavior + esac +done + +# Colors +R='\033[31m' G='\033[32m' Y='\033[33m' B='\033[34m' C='\033[36m' NC='\033[0m' BOLD='\033[1m' + +ok() { echo -e "${G}✓${NC} $1"; } +err() { echo -e "${R}✗${NC} $1"; } +warn() { echo -e "${Y}⚠${NC} $1"; } +info() { echo -e "${C}ℹ${NC} $1"; } + +confirm() { + $AUTO_YES && return 0 + read -p "$1 [Y/n] " -n 1 -r; echo + [[ ! $REPLY =~ ^[Nn]$ ]] +} + +# Check functions - return "ok:version" or "missing" or "wrong:version" +chk_brew() { command -v brew &>/dev/null && echo "ok:$(brew --version | head -1 | cut -d' ' -f2)" || echo "missing"; } +chk_nvm() { [[ -s "$HOME/.nvm/nvm.sh" ]] && echo "ok" || echo "missing"; } +chk_node() { command -v node &>/dev/null && { v=$(node -v 2>/dev/null | tr -d 'v'); [[ -n "$v" && "${v%%.*}" -ge $NODE_MAJOR ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; } +chk_watch() { command -v watchman &>/dev/null && echo "ok:$(watchman --version 2>/dev/null)" || echo "missing"; } +chk_rbenv() { command -v rbenv &>/dev/null && echo "ok" || echo "missing"; } +chk_ruby() { command -v ruby &>/dev/null && { v=$(ruby -v 2>/dev/null | cut -d' ' -f2); [[ "$v" == "$RUBY_VERSION"* ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; } +chk_pods() { command -v pod &>/dev/null && echo "ok:$(pod --version 2>/dev/null)" || echo "missing"; } +chk_bundler() { command -v bundle &>/dev/null && echo "ok" || echo "missing"; } +chk_java() { command -v java &>/dev/null && { v=$(java -version 2>&1 | head -1 | cut -d'"' -f2); [[ "$v" == 17* ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; } +chk_xcode() { xcode-select -p &>/dev/null && [[ "$(xcode-select -p)" == *Xcode.app* ]] && echo "ok" || echo "missing"; } +chk_studio() { [[ -d "/Applications/Android Studio.app" ]] && echo "ok" || echo "missing"; } +chk_sdk() { [[ -d "${ANDROID_HOME:-$HOME/Library/Android/sdk}" ]] && echo "ok" || echo "missing"; } +chk_ndk() { [[ -d "${ANDROID_HOME:-$HOME/Library/Android/sdk}/ndk/27.0.12077973" ]] && echo "ok" || echo "missing"; } +chk_shell() { local rc=~/.zshrc; [[ "$SHELL" == *bash* ]] && rc=~/.bashrc; grep -q "ANDROID_HOME" "$rc" 2>/dev/null && echo "ok" || echo "missing"; } + +# Install functions +inst_brew() { /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; } +inst_nvm() { curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; } +inst_node() { export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; nvm install $NODE_MAJOR; } +inst_watch() { brew install watchman; } +inst_rbenv() { brew install rbenv; eval "$(rbenv init -)"; } +inst_ruby() { eval "$(rbenv init -)" 2>/dev/null; rbenv install "$RUBY_VERSION"; rbenv rehash; } +inst_pods() { gem install cocoapods; } +inst_bundler() { gem install bundler; } +inst_java() { brew install openjdk@17; sudo ln -sfn "$(brew --prefix openjdk@17)/libexec/openjdk.jdk" /Library/Java/JavaVirtualMachines/openjdk-17.jdk 2>/dev/null || true; } + +inst_shell() { + local rc=~/.zshrc + [[ "$SHELL" == *bash* ]] && rc=~/.bashrc + + # Check if already configured + if grep -q "# Self.xyz Dev Environment" "$rc" 2>/dev/null; then + ok "Shell already configured" + return 0 + fi + + info "Adding environment to $rc..." + cat >> "$rc" << 'EOF' + +# Self.xyz Dev Environment +export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || echo "") +export ANDROID_HOME=~/Library/Android/sdk +export ANDROID_SDK_ROOT=$ANDROID_HOME +export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools +command -v rbenv &>/dev/null && eval "$(rbenv init -)" +export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" +EOF + ok "Shell configured. Run: source $rc" +} + +# Main +echo -e "\n${C}${BOLD}═══ Self.xyz macOS Setup ═══${NC}\n" + +# Interactive mode selection (if no flags provided) +if [[ "$CHECK_ONLY" == false && "$AUTO_YES" == false ]]; then + echo "How would you like to run the setup?" + echo "" + echo " 1) Check only - just show what's installed/missing" + echo " 2) Interactive setup - check and confirm before installing (recommended)" + echo " 3) Auto-install - install everything without prompts" + echo "" + read -p "Enter choice [1]: " -n 1 -r choice + echo "" + case $choice in + 2) ;; # interactive setup + 3) AUTO_YES=true ;; + *) CHECK_ONLY=true ;; # default: check only + esac + echo "" +fi + +# Define deps: name|check_fn|install_fn|manual_msg +DEPS=( + "Homebrew|chk_brew|inst_brew|" + "nvm|chk_nvm|inst_nvm|" + "Node.js $NODE_MAJOR|chk_node|inst_node|" + "Watchman|chk_watch|inst_watch|" + "rbenv|chk_rbenv|inst_rbenv|" + "Ruby $RUBY_VERSION|chk_ruby|inst_ruby|" + "CocoaPods|chk_pods|inst_pods|" + "Bundler|chk_bundler|inst_bundler|" + "Java 17|chk_java|inst_java|" + "Xcode|chk_xcode||Install from App Store: https://apps.apple.com/app/xcode/id497799835" + "Android Studio|chk_studio||Download: https://developer.android.com/studio" + "Android SDK|chk_sdk||Open Android Studio → SDK Manager" + "Android NDK|chk_ndk||SDK Manager → SDK Tools → NDK 27.0.12077973" + "Shell Config|chk_shell|inst_shell|" +) + +MISSING=() +MANUAL=() + +info "Checking dependencies...\n" +for dep in "${DEPS[@]}"; do + IFS='|' read -r name chk inst manual <<< "$dep" + status=$($chk) + + if [[ "$status" == ok* ]]; then + ver="${status#ok:}"; [[ -n "$ver" && "$ver" != "ok" ]] && ok "$name ($ver)" || ok "$name" + elif [[ -n "$manual" ]]; then + warn "$name - manual install required" + MANUAL+=("$name|$manual") + else + err "$name - not installed" + [[ -n "$inst" ]] && MISSING+=("$name|$inst") + fi +done + +$CHECK_ONLY && { + [[ ${#MANUAL[@]} -gt 0 ]] && { echo -e "\n${Y}Manual installs:${NC}"; for m in "${MANUAL[@]}"; do IFS='|' read -r n msg <<< "$m"; echo " $n: $msg"; done; } + exit 0 +} + +# Install missing +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo -e "\n${B}Missing:${NC} $(printf '%s\n' "${MISSING[@]}" | cut -d'|' -f1 | tr '\n' ', ' | sed 's/, $//')" + if confirm "Install all?"; then + for m in "${MISSING[@]}"; do + IFS='|' read -r name fn <<< "$m" + info "Installing $name..." + $fn && ok "$name installed" || err "Failed: $name" + done + fi +fi + +# Manual instructions +[[ ${#MANUAL[@]} -gt 0 ]] && { echo -e "\n${Y}Manual installs needed:${NC}"; for m in "${MANUAL[@]}"; do IFS='|' read -r n msg <<< "$m"; echo " $n: $msg"; done; } + +# Yarn install +echo "" +if confirm "Run 'yarn install' in repo root?"; then + info "Running yarn install..." + set +e # Temporarily disable exit-on-error + cd "$REPO_ROOT" && yarn install + yarn_exit=$? + set -e # Re-enable exit-on-error + + if [[ $yarn_exit -eq 0 ]]; then + ok "Done!" + else + err "Yarn install failed (exit code: $yarn_exit)" + warn "This may be due to network issues or registry timeouts" + info "Try running manually: cd $REPO_ROOT && yarn install" + fi +fi + +echo -e "\n${G}${BOLD}Setup complete!${NC} Open a new terminal, then: cd $APP_DIR && yarn ios\n" From 43fb39d3d44323ac0f4be28ebead8126f7affed7 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 5 Jan 2026 20:17:28 -0800 Subject: [PATCH 09/47] SELF-1680: Starfall mobile push notifications (#1548) * move to personal mcp * add new nova pin screen * rename screen * update nova route * unblock local dev building * rename nova to starfall * move to dev dependency * move to dependencies * add correct package * save wip * save wip * save wip fixes * rename self logo * fix screen logos * fix order * add starfall api to fetch push notification code * agent feedback * fix tests, minor agent feedback * abstract component * rename topic * re-add button props * fix linting --- .cursor/mcp.json | 16 +- app/jest.setup.js | 3 + app/package.json | 1 + app/src/assets/icons/checkmark_white.svg | 3 + app/src/assets/images/bg_starfall_push.png | Bin 0 -> 178106 bytes app/src/assets/logos/opera_minipay.svg | 12 + .../{icons/logo_white.svg => logos/self.svg} | 0 .../starfall/StarfallLogoHeader.tsx | 40 ++ app/src/components/starfall/StarfallPIN.tsx | 62 +++ app/src/navigation/index.tsx | 3 + app/src/navigation/starfall.ts | 18 + app/src/screens/app/GratificationScreen.tsx | 4 +- app/src/screens/dev/DevSettingsScreen.tsx | 6 +- .../starfall/StarfallPushCodeScreen.tsx | 255 +++++++++++++ app/src/services/starfall/pushCodeService.ts | 67 ++++ app/tests/src/navigation.test.tsx | 1 + app/tests/src/navigation/index.test.ts | 131 ------- .../src/screens/GratificationScreen.test.tsx | 2 +- .../src/components/buttons/AbstractButton.tsx | 46 ++- .../src/components/buttons/PrimaryButton.tsx | 30 +- .../components/buttons/SecondaryButton.tsx | 40 +- .../mobile-sdk-alpha/src/components/index.ts | 28 +- .../buttons/AbstractButton.test.tsx | 352 ++++++++++++++++++ packages/mobile-sdk-alpha/tests/setup.ts | 14 + yarn.lock | 11 + 25 files changed, 948 insertions(+), 197 deletions(-) create mode 100644 app/src/assets/icons/checkmark_white.svg create mode 100644 app/src/assets/images/bg_starfall_push.png create mode 100644 app/src/assets/logos/opera_minipay.svg rename app/src/assets/{icons/logo_white.svg => logos/self.svg} (100%) create mode 100644 app/src/components/starfall/StarfallLogoHeader.tsx create mode 100644 app/src/components/starfall/StarfallPIN.tsx create mode 100644 app/src/navigation/starfall.ts create mode 100644 app/src/screens/starfall/StarfallPushCodeScreen.tsx create mode 100644 app/src/services/starfall/pushCodeService.ts delete mode 100644 app/tests/src/navigation/index.test.ts create mode 100644 packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx diff --git a/.cursor/mcp.json b/.cursor/mcp.json index b2657c17c..7b71337c5 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,16 +1,4 @@ { - "mcpServers": { - "giga": { - "command": "npx", - "args": [ - "-y", - "mcp-remote@latest", - "https://mcp.gigamind.dev/mcp" - ] - } - }, - "settings": { - "disableAutoPRAnalysis": true, - "manualReviewEnabled": true - } + "mcpServers": {}, + "settings": {} } diff --git a/app/jest.setup.js b/app/jest.setup.js index a48de7dc0..1d0275e04 100644 --- a/app/jest.setup.js +++ b/app/jest.setup.js @@ -1031,6 +1031,9 @@ jest.mock('@react-native-clipboard/clipboard', () => ({ hasString: jest.fn().mockResolvedValue(false), })); +// Mock react-native-linear-gradient +jest.mock('react-native-linear-gradient', () => 'LinearGradient'); + // Mock react-native-localize jest.mock('react-native-localize', () => ({ getLocales: jest.fn().mockReturnValue([ diff --git a/app/package.json b/app/package.json index 2d837d22d..26ad5c742 100644 --- a/app/package.json +++ b/app/package.json @@ -154,6 +154,7 @@ "react-native-haptic-feedback": "^2.3.3", "react-native-inappbrowser-reborn": "^3.7.0", "react-native-keychain": "^10.0.0", + "react-native-linear-gradient": "^2.8.3", "react-native-localize": "^3.5.2", "react-native-logs": "^5.3.0", "react-native-nfc-manager": "3.16.3", diff --git a/app/src/assets/icons/checkmark_white.svg b/app/src/assets/icons/checkmark_white.svg new file mode 100644 index 000000000..e903e1c42 --- /dev/null +++ b/app/src/assets/icons/checkmark_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/assets/images/bg_starfall_push.png b/app/src/assets/images/bg_starfall_push.png new file mode 100644 index 0000000000000000000000000000000000000000..ad6f11e95dc16cef5b351ec841cd78b4fb875aed GIT binary patch literal 178106 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 p5~9yRGz#V=fTX@BzN=jo{eL + + + + + + + + + + + diff --git a/app/src/assets/icons/logo_white.svg b/app/src/assets/logos/self.svg similarity index 100% rename from app/src/assets/icons/logo_white.svg rename to app/src/assets/logos/self.svg diff --git a/app/src/components/starfall/StarfallLogoHeader.tsx b/app/src/components/starfall/StarfallLogoHeader.tsx new file mode 100644 index 000000000..4a8f30352 --- /dev/null +++ b/app/src/components/starfall/StarfallLogoHeader.tsx @@ -0,0 +1,40 @@ +// 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 React from 'react'; +import { View, XStack } from 'tamagui'; + +import { black, zinc800 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; + +import CheckmarkIcon from '@/assets/icons/checkmark_white.svg'; +import OperaLogo from '@/assets/logos/opera_minipay.svg'; +import SelfLogo from '@/assets/logos/self.svg'; + +export const StarfallLogoHeader: React.FC = () => ( + + {/* Opera MiniPay logo */} + + + + + {/* Checkmark icon */} + + + + + {/* Self logo */} + + + + +); diff --git a/app/src/components/starfall/StarfallPIN.tsx b/app/src/components/starfall/StarfallPIN.tsx new file mode 100644 index 000000000..fb0cac18b --- /dev/null +++ b/app/src/components/starfall/StarfallPIN.tsx @@ -0,0 +1,62 @@ +// 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 React from 'react'; +import { Text, XStack, YStack } from 'tamagui'; + +import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +export interface StarfallPINProps { + code: string; +} + +export const StarfallPIN: React.FC = ({ code }) => { + // Split the code into individual digits (expects 4 digits) + const digits = code.split('').slice(0, 4); + + // Pad with empty strings if less than 4 digits + while (digits.length < 4) { + digits.push(''); + } + + return ( + + {digits.map((digit, index) => ( + + + {digit} + + + ))} + + ); +}; diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index b481036cb..4ce48e8f3 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -27,6 +27,7 @@ import documentsScreens from '@/navigation/documents'; import homeScreens from '@/navigation/home'; import onboardingScreens from '@/navigation/onboarding'; import sharedScreens from '@/navigation/shared'; +import starfallScreens from '@/navigation/starfall'; import verificationScreens from '@/navigation/verification'; import type { ModalNavigationParams } from '@/screens/app/ModalScreen'; import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen'; @@ -41,6 +42,7 @@ export const navigationScreens = { ...verificationScreens, ...accountScreens, ...sharedScreens, + ...starfallScreens, ...devScreens, // allow in production for testing }; @@ -158,6 +160,7 @@ export type RootStackParamList = Omit< Gratification: { points?: number; }; + StarfallPushCode: undefined; // Home screens Home: { diff --git a/app/src/navigation/starfall.ts b/app/src/navigation/starfall.ts new file mode 100644 index 000000000..6de84c5cf --- /dev/null +++ b/app/src/navigation/starfall.ts @@ -0,0 +1,18 @@ +// 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 { NativeStackNavigationOptions } from '@react-navigation/native-stack'; + +import StarfallPushCodeScreen from '@/screens/starfall/StarfallPushCodeScreen'; + +const starfallScreens = { + StarfallPushCode: { + screen: StarfallPushCodeScreen, + options: { + headerShown: false, + } as NativeStackNavigationOptions, + }, +}; + +export default starfallScreens; diff --git a/app/src/screens/app/GratificationScreen.tsx b/app/src/screens/app/GratificationScreen.tsx index cecd21144..e9c378260 100644 --- a/app/src/screens/app/GratificationScreen.tsx +++ b/app/src/screens/app/GratificationScreen.tsx @@ -26,8 +26,8 @@ import { } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import { dinot, dinotBold } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; -import LogoWhite from '@/assets/icons/logo_white.svg'; import GratificationBg from '@/assets/images/gratification_bg.svg'; +import SelfLogo from '@/assets/logos/self.svg'; import type { RootStackParamList } from '@/navigation'; const GratificationScreen: React.FC = () => { @@ -160,7 +160,7 @@ const GratificationScreen: React.FC = () => { > {/* Logo icon */} - + {/* Points display */} diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index 09b171929..143a90b8b 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -660,11 +660,11 @@ const DevSettingsScreen: React.FC = ({}) => { > handleTopicToggle(['nova'], 'Nova')} + onToggle={() => handleTopicToggle(['nova'], 'Starfall')} /> = ({}) => { onToggle={() => handleTopicToggle(['general'], 'General')} /> { + const navigation = useNavigation(); + const [code, setCode] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isCopied, setIsCopied] = useState(false); + const copyTimeoutRef = useRef(null); + + const handleFetchCode = async () => { + try { + setIsLoading(true); + setError(null); + confirmTap(); + + const walletAddress = await getOrGeneratePointsAddress(); + const fetchedCode = await fetchPushCode(walletAddress); + + setCode(fetchedCode); + } catch (err) { + console.error('Failed to fetch push code:', err); + setError('Failed to generate code. Please try again.'); + setCode(null); // Clear stale code on error + } finally { + setIsLoading(false); + } + }; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + + const handleRetry = () => { + handleFetchCode(); + }; + + const handleCopyCode = async () => { + if (!code || code === DASH_CODE) { + return; + } + + try { + confirmTap(); + await Clipboard.setString(code); + setIsCopied(true); + + // Clear any existing timeout before creating a new one + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + + // Reset after 1.65 seconds + copyTimeoutRef.current = setTimeout(() => { + setIsCopied(false); + copyTimeoutRef.current = null; + }, 1650); + } catch (copyError) { + console.error('Failed to copy to clipboard:', copyError); + } + }; + + const handleDismiss = () => { + confirmTap(); + navigation.goBack(); + }; + + return ( + + + {/* Colorful background image */} + + {/* Fade to black overlay - stronger at bottom */} + + + + {/* Content container */} + + {/* App logos section */} + + + {/* Title and content */} + + + Your Starfall code awaits + + + + + + Open Starfall in Opera MiniPay and enter this four digit code + to continue your journey. + + + + + + + + {/* Error message */} + {error && ( + + {error} + + )} + + + + + + + {/* Bottom buttons */} + + {/* Debug: Fetch code button or Retry button on error */} + {error ? ( + + Retry + + ) : ( + + {isLoading ? 'Fetching...' : 'Fetch code'} + + )} + + + {isCopied ? 'Code copied!' : 'Copy code'} + + + Dismiss + + + + + ); +}; + +export default StarfallPushCodeScreen; diff --git a/app/src/services/starfall/pushCodeService.ts b/app/src/services/starfall/pushCodeService.ts new file mode 100644 index 000000000..c55a65f69 --- /dev/null +++ b/app/src/services/starfall/pushCodeService.ts @@ -0,0 +1,67 @@ +// 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 { POINTS_API_BASE_URL } from '@/services/points/constants'; + +const REQUEST_TIMEOUT_MS = 30000; // 30 seconds + +/** + * Fetches a one-time push code for the specified wallet address. + * The code has a TTL of 30 minutes and refreshes with each call. + * + * @param walletAddress - The wallet address to generate a push code for + * @returns The 4-digit push code as a string + * @throws Error if the API request fails or times out + */ +export async function fetchPushCode(walletAddress: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, REQUEST_TIMEOUT_MS); + + try { + const response = await fetch( + `${POINTS_API_BASE_URL}/push/wallet/${walletAddress}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: controller.signal, + }, + ); + + // Clear timeout on successful response + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error( + `Failed to fetch push code: ${response.status} ${response.statusText}`, + ); + } + + const code = await response.json(); + + // The API returns a JSON string like "5932" + if (typeof code !== 'string' || code.length !== 4) { + throw new Error('Invalid push code format received from API'); + } + + return code; + } catch (error) { + // Clear timeout on error + clearTimeout(timeoutId); + + // Handle abort/timeout specifically + if (error instanceof Error && error.name === 'AbortError') { + console.error('Push code request timed out'); + throw new Error( + 'Request timed out. Please check your connection and try again.', + ); + } + + console.error('Error fetching push code:', error); + throw error; + } +} diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index fbb8e3514..5f0ea721c 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -84,6 +84,7 @@ describe('navigation', () => { 'Settings', 'ShowRecoveryPhrase', 'Splash', + 'StarfallPushCode', 'WebView', ]); }); diff --git a/app/tests/src/navigation/index.test.ts b/app/tests/src/navigation/index.test.ts deleted file mode 100644 index 4897dd12c..000000000 --- a/app/tests/src/navigation/index.test.ts +++ /dev/null @@ -1,131 +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. - -// Mock the navigation module to avoid deep import chains that overwhelm the parser -jest.mock('@/navigation', () => { - const mockScreens = { - // App screens - Home: {}, - Launch: {}, - Loading: {}, - Modal: {}, - Gratification: {}, - WebView: {}, - Points: {}, - // Onboarding screens - Disclaimer: {}, - Splash: {}, - // Documents screens - IDPicker: {}, - IdDetails: {}, - CountryPicker: { - statusBar: { hidden: true, style: 'dark' }, - }, - DocumentCamera: {}, - DocumentCameraTrouble: {}, - DocumentDataInfo: {}, - DocumentDataNotFound: {}, - DocumentNFCMethodSelection: {}, - DocumentNFCScan: {}, - DocumentNFCTrouble: {}, - DocumentOnboarding: {}, - ManageDocuments: {}, - // Verification screens - ConfirmBelonging: {}, - Prove: {}, - ProofHistory: {}, - ProofHistoryDetail: {}, - ProofRequestStatus: {}, - QRCodeViewFinder: {}, - QRCodeTrouble: {}, - // Account screens - AccountRecovery: {}, - AccountRecoveryChoice: {}, - AccountVerifiedSuccess: {}, - CloudBackupSettings: {}, - SaveRecoveryPhrase: {}, - ShowRecoveryPhrase: {}, - RecoverWithPhrase: {}, - Settings: {}, - Referral: {}, - DeferredLinkingInfo: {}, - // Shared screens - ComingSoon: {}, - // Dev screens - DevSettings: {}, - DevFeatureFlags: {}, - DevHapticFeedback: {}, - DevLoadingScreen: {}, - DevPrivateKey: {}, - CreateMock: {}, - MockDataDeepLink: {}, - // Aadhaar screens - AadhaarUpload: {}, - AadhaarUploadSuccess: {}, - AadhaarUploadError: {}, - }; - - return { - navigationScreens: mockScreens, - navigationRef: { current: null }, - }; -}); - -describe('navigation', () => { - it('should have the correct navigation screens', () => { - const navigationScreens = require('@/navigation').navigationScreens; - const listOfScreens = Object.keys(navigationScreens).sort(); - expect(listOfScreens).toEqual([ - 'AadhaarUpload', - 'AadhaarUploadError', - 'AadhaarUploadSuccess', - 'AccountRecovery', - 'AccountRecoveryChoice', - 'AccountVerifiedSuccess', - 'CloudBackupSettings', - 'ComingSoon', - 'ConfirmBelonging', - 'CountryPicker', - 'CreateMock', - 'DeferredLinkingInfo', - 'DevFeatureFlags', - 'DevHapticFeedback', - 'DevLoadingScreen', - 'DevPrivateKey', - 'DevSettings', - 'Disclaimer', - 'DocumentCamera', - 'DocumentCameraTrouble', - 'DocumentDataInfo', - 'DocumentDataNotFound', - 'DocumentNFCMethodSelection', - 'DocumentNFCScan', - 'DocumentNFCTrouble', - 'DocumentOnboarding', - 'Gratification', - 'Home', - 'IDPicker', - 'IdDetails', - 'Launch', - 'Loading', - 'ManageDocuments', - 'MockDataDeepLink', - 'Modal', - 'Points', - 'ProofHistory', - 'ProofHistoryDetail', - 'ProofRequestStatus', - 'Prove', - 'QRCodeTrouble', - 'QRCodeViewFinder', - 'RecoverWithPhrase', - 'Referral', - 'SaveRecoveryPhrase', - 'Settings', - 'ShowRecoveryPhrase', - 'Splash', - 'WebView', - ]); - }); -}); diff --git a/app/tests/src/screens/GratificationScreen.test.tsx b/app/tests/src/screens/GratificationScreen.test.tsx index 608f4d270..5c4128398 100644 --- a/app/tests/src/screens/GratificationScreen.test.tsx +++ b/app/tests/src/screens/GratificationScreen.test.tsx @@ -97,7 +97,7 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({ })); jest.mock('@/assets/icons/arrow_left.svg', () => 'ArrowLeft'); -jest.mock('@/assets/icons/logo_white.svg', () => 'LogoWhite'); +jest.mock('@/assets/logos/self.svg', () => 'SelfLogo'); const mockUseNavigation = useNavigation as jest.MockedFunction< typeof useNavigation diff --git a/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx index a9f8a45b6..dabffb598 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx @@ -15,16 +15,40 @@ export interface ButtonProps extends PressableProps { animatedComponent?: React.ReactNode; trackEvent?: string; borderWidth?: number; + borderColor?: string; + fontSize?: number; onLayout?: (event: LayoutChangeEvent) => void; } +/** + * Standard interface for extracting style props from button components. + * Use this to separate style-related props from other button props. + */ +export interface ExtractedButtonStyleProps { + borderWidth?: number; + borderColor?: string; + fontSize?: number; +} + interface AbstractButtonProps extends ButtonProps { bgColor: string; borderColor?: string; borderWidth?: number; + fontSize?: number; color: string; } +// Helper to extract border props from style object +function extractBorderFromStyle(style: ViewStyle | undefined): { + borderColor?: string; + borderWidth?: number; + restStyle: ViewStyle; +} { + if (!style) return { restStyle: {} }; + const { borderColor, borderWidth, ...restStyle } = style; + return { borderColor: borderColor as string | undefined, borderWidth, restStyle }; +} + /* Base Button component that can be used to create different types of buttons use PrimaryButton and SecondaryButton instead of this component or create a new button component @@ -35,8 +59,9 @@ export default function AbstractButton({ children, bgColor, color, - borderColor, - borderWidth = 4, + borderColor: propBorderColor, + borderWidth: propBorderWidth, + fontSize, style, animatedComponent, trackEvent, @@ -44,7 +69,15 @@ export default function AbstractButton({ ...props }: AbstractButtonProps) { const selfClient = useSelfClient(); - const hasBorder = borderColor ? true : false; + + // Extract border from style prop if provided there + const flatStyle = StyleSheet.flatten(style) as ViewStyle | undefined; + const { borderColor: styleBorderColor, borderWidth: styleBorderWidth, restStyle } = extractBorderFromStyle(flatStyle); + + // Props take precedence over style + const borderColor = propBorderColor ?? styleBorderColor; + const borderWidth = propBorderWidth ?? styleBorderWidth; + const hasBorder = borderColor != null; const handlePress = (e: GestureResponderEvent) => { if (trackEvent) { @@ -69,17 +102,16 @@ export default function AbstractButton({ { backgroundColor: bgColor }, hasBorder ? { - borderWidth: borderWidth, + borderWidth: borderWidth ?? 1, borderColor: borderColor, - padding: 20 - borderWidth, // Adjust padding to maintain total size } : Platform.select({ web: { borderWidth: 0 }, default: {} }), !animatedComponent && pressed ? pressedStyle : {}, - style as ViewStyle, + restStyle as ViewStyle, ]} > {animatedComponent} - {children} + {children} ); } diff --git a/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx index 99bfa3a28..74bee5fdf 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx @@ -4,23 +4,41 @@ import { amber50, black, slate300, white } from '../../constants/colors'; import { normalizeBorderWidth } from '../../utils/styleUtils'; -import type { ButtonProps } from './AbstractButton'; +import type { ButtonProps, ExtractedButtonStyleProps } from './AbstractButton'; import AbstractButton from './AbstractButton'; +/** + * Extract standard style props for primary button. + * Separates border and font props from other button props. + */ +function extractPrimaryButtonStyleProps(props: Omit): { + styleProps: ExtractedButtonStyleProps; + restProps: Omit; +} { + const { borderWidth, borderColor, fontSize, ...restProps } = props; + return { + styleProps: { + borderWidth: normalizeBorderWidth(borderWidth), + borderColor, + fontSize, + }, + restProps, + }; +} + export function PrimaryButton({ children, ...props }: ButtonProps) { - const { borderWidth, ...restProps } = props; + const { styleProps, restProps } = extractPrimaryButtonStyleProps(props); const isDisabled = restProps.disabled; const bgColor = isDisabled ? white : black; const color = isDisabled ? slate300 : amber50; - const borderColor = isDisabled ? slate300 : undefined; - - const numericBorderWidth = normalizeBorderWidth(borderWidth); + const borderColor = isDisabled ? slate300 : styleProps.borderColor; return ( diff --git a/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx index 0212bd9c8..814674a06 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx @@ -4,25 +4,47 @@ import { slate200, slate300, slate500, white } from '../../constants/colors'; import { normalizeBorderWidth } from '../../utils/styleUtils'; -import type { ButtonProps } from './AbstractButton'; +import type { ButtonProps, ExtractedButtonStyleProps } from './AbstractButton'; import AbstractButton from './AbstractButton'; -export function SecondaryButton({ children, ...props }: ButtonProps) { - const { borderWidth, ...restProps } = props; +export interface SecondaryButtonProps extends ButtonProps { + textColor?: string; +} + +/** + * Extract standard style props for secondary button. + * Separates border and font props from other button props. + */ +function extractSecondaryButtonStyleProps(props: Omit): { + styleProps: ExtractedButtonStyleProps; + restProps: Omit; +} { + const { borderWidth, borderColor, fontSize, ...restProps } = props; + return { + styleProps: { + borderWidth: normalizeBorderWidth(borderWidth), + borderColor, + fontSize, + }, + restProps, + }; +} + +export function SecondaryButton({ children, textColor, ...props }: SecondaryButtonProps) { + const { styleProps, restProps } = extractSecondaryButtonStyleProps(props); const isDisabled = restProps.disabled; const bgColor = isDisabled ? white : slate200; - const color = isDisabled ? slate300 : slate500; - const borderColor = isDisabled ? slate200 : undefined; - - const numericBorderWidth = normalizeBorderWidth(borderWidth); + const color = textColor ?? (isDisabled ? slate300 : slate500); + const borderColor = isDisabled ? slate300 : styleProps.borderColor; return ( {children} diff --git a/packages/mobile-sdk-alpha/src/components/index.ts b/packages/mobile-sdk-alpha/src/components/index.ts index 9f76f5f5d..02a33013d 100644 --- a/packages/mobile-sdk-alpha/src/components/index.ts +++ b/packages/mobile-sdk-alpha/src/components/index.ts @@ -2,53 +2,33 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. +export type { ButtonProps, ExtractedButtonStyleProps } from './buttons/AbstractButton'; +// Type exports +export type { SecondaryButtonProps } from './buttons/SecondaryButton'; export type { ViewProps } from './layout/View'; +// Button components export { default as AbstractButton } from './buttons/AbstractButton'; - -// Typography components export { default as Additional } from './typography/Additional'; - -// Layout components export { BodyText } from './typography/BodyText'; export { Button } from './layout/Button'; export { default as ButtonsContainer } from './ButtonsContainer'; export { Caption } from './typography/Caption'; export { default as Caution } from './typography/Caution'; - export { default as Description } from './typography/Description'; - export { DescriptionTitle } from './typography/DescriptionTitle'; - export { HeldPrimaryButton } from './buttons/PrimaryButtonLongHold'; - export { HeldPrimaryButtonProveScreen } from './buttons/HeldPrimaryButtonProveScreen'; - export { MRZScannerView } from './MRZScannerView'; - -// Button components export { PrimaryButton } from './buttons/PrimaryButton'; - -// Flag components export { RoundFlag } from './flag/RoundFlag'; - export { SecondaryButton } from './buttons/SecondaryButton'; - export { SubHeader } from './typography/SubHeader'; - export { Text } from './layout/Text'; - export { default as TextsContainer } from './TextsContainer'; - export { Title } from './typography/Title'; - export { View } from './layout/View'; - export { XStack } from './layout/XStack'; - -// Export types export { YStack } from './layout/YStack'; - export { pressedStyle } from './buttons/pressedStyle'; - export { typography } from './typography/styles'; diff --git a/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx b/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx new file mode 100644 index 000000000..e10a0cf32 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx @@ -0,0 +1,352 @@ +// 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 type { ReactNode } from 'react'; +import { Platform } from 'react-native'; +import { describe, expect, it, vi } from 'vitest'; + +import AbstractButton from '../../../src/components/buttons/AbstractButton'; +import { SelfClientProvider } from '../../../src/index'; +import { mockAdapters } from '../../utils/testHelpers'; + +import { render } from '@testing-library/react'; + +// Helper to wrap component in SelfClientProvider +function TestWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +describe('AbstractButton', () => { + describe('borderColor prop', () => { + it('should apply borderColor from prop', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + // Note: In jsdom, styles are applied as inline styles or style objects + // The actual style checking depends on how react-native-web or mocks handle it + }); + + it('should apply borderColor from style prop', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should prioritize borderColor prop over style', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should handle borderWidth prop', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('fontSize prop', () => { + it('should apply fontSize from prop', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + const text = button?.querySelector('span'); + expect(text).toBeTruthy(); + expect(text?.textContent).toBe('Test Button'); + }); + + it('should use default fontSize of 18 when not provided', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + const text = button?.querySelector('span'); + expect(text).toBeTruthy(); + }); + + it('should accept various fontSize values', () => { + const fontSizes = [12, 16, 20, 24, 28, 32]; + + fontSizes.forEach(fontSize => { + const { container } = render( + + + Test {fontSize} + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + }); + + describe('Platform.select behavior', () => { + it('should apply borderWidth: 0 on web when no border is specified', () => { + // Platform is mocked as 'web' in setup.ts + expect(Platform.OS).toBe('web'); + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should not apply borderWidth: 0 when border is specified', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('event tracking', () => { + it('should call trackEvent when trackEvent prop is provided', () => { + // This test verifies the trackEvent functionality exists + // The actual implementation is tested through the SelfClient + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should parse event category from trackEvent string', () => { + // Tests that "Category: Event" format gets parsed to "Event" + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('style prop handling', () => { + it('should merge style prop with internal styles', () => { + const customStyle = { + padding: 10, + backgroundColor: 'blue', + }; + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should handle StyleSheet.flatten for style prop', () => { + const style1 = { padding: 10 }; + const style2 = { margin: 5 }; + const combinedStyle = [style1, style2]; + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('disabled state', () => { + it('should accept disabled prop', () => { + const { container } = render( + + + Disabled Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('animatedComponent', () => { + it('should render animatedComponent when provided', () => { + const AnimatedComponent =
Animated
; + + const { container, getByTestId } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(getByTestId('animated')).toBeTruthy(); + }); + }); + + describe('cross-platform compatibility', () => { + it('should render consistently on web platform', () => { + // Verify Platform.OS is 'web' as expected from setup.ts + expect(Platform.OS).toBe('web'); + + const { container } = render( + + + Cross-Platform Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toBe('Cross-Platform Button'); + }); + + it('should handle Platform.select correctly', () => { + // Verify that Platform.select returns web or default values + const result = Platform.select({ web: 'web-value', default: 'default-value' }); + expect(result).toBe('web-value'); + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('onPress handling', () => { + it('should call onPress when button is pressed', () => { + const onPressMock = vi.fn(); + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + + // Note: In jsdom environment, we can't easily simulate Pressable's onPress + // This test verifies the button is renderable with onPress prop + }); + }); + + describe('children rendering', () => { + it('should render children as text', () => { + const { container } = render( + + + Button Text + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toBe('Button Text'); + }); + + it('should render complex children', () => { + const { container } = render( + + + {'Button '} + {'with '} + {'multiple '} + {'parts'} + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toBe('Button with multiple parts'); + }); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/setup.ts b/packages/mobile-sdk-alpha/tests/setup.ts index 5eeea990e..b249fdcad 100644 --- a/packages/mobile-sdk-alpha/tests/setup.ts +++ b/packages/mobile-sdk-alpha/tests/setup.ts @@ -7,6 +7,8 @@ * Reduces console noise during testing and mocks React Native modules */ +import { createElement } from 'react'; + const originalConsole = { warn: console.warn, error: console.error, @@ -48,10 +50,22 @@ vi.mock('react-native', () => ({ requireNativeComponent: vi.fn(() => 'div'), StyleSheet: { create: vi.fn(styles => styles), + flatten: vi.fn(style => { + if (!style) return {}; + if (Array.isArray(style)) { + return style.reduce((acc, s) => ({ ...acc, ...s }), {}); + } + return style; + }), }, Image: 'div', Text: 'span', View: 'div', + Pressable: vi.fn(({ children, style, ...props }) => { + // Handle style as function (for pressed state) + const computedStyle = typeof style === 'function' ? style({ pressed: false }) : style; + return createElement('button', { ...props, style: computedStyle }, children); + }), TouchableOpacity: 'button', ScrollView: 'div', FlatList: 'div', diff --git a/yarn.lock b/yarn.lock index 4c438ead4..acf20c195 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8663,6 +8663,7 @@ __metadata: react-native-haptic-feedback: "npm:^2.3.3" react-native-inappbrowser-reborn: "npm:^3.7.0" react-native-keychain: "npm:^10.0.0" + react-native-linear-gradient: "npm:^2.8.3" react-native-localize: "npm:^3.5.2" react-native-logs: "npm:^5.3.0" react-native-nfc-manager: "npm:3.16.3" @@ -29892,6 +29893,16 @@ __metadata: languageName: node linkType: hard +"react-native-linear-gradient@npm:^2.8.3": + version: 2.8.3 + resolution: "react-native-linear-gradient@npm:2.8.3" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/cd41bf28e9f468173f1e5e768685128ebf8bbf9077710e43b63482c1a76f37bff8ab3d1d6adfd7b4d54e648672356c02bea46c47cdbdb1844ebe5c5caf720114 + languageName: node + linkType: hard + "react-native-localize@npm:^3.5.2, react-native-localize@npm:^3.5.4": version: 3.6.0 resolution: "react-native-localize@npm:3.6.0" From d02a730970e491c98354b590481057f6c62290f9 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 6 Jan 2026 12:59:16 -0800 Subject: [PATCH 10/47] remove option to change target bump build pr branch. default to dev (#1550) --- .github/workflows/mobile-deploy.yml | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml index cef3ccdd3..2d273176d 100644 --- a/.github/workflows/mobile-deploy.yml +++ b/.github/workflows/mobile-deploy.yml @@ -16,7 +16,7 @@ name: Mobile Deploy # - This allows testing from feature branches before merging to dev/staging # # Version Bump PR: After successful build, creates PR to bump version -# - Default target: 'dev' branch (can be overridden with bump_target_branch input) +# - Target: 'dev' branch # - Workflow checks out the target branch, applies version changes, and creates PR # - This separates the build source from the version bump destination # @@ -25,8 +25,6 @@ name: Mobile Deploy # - Merge PR to staging → builds from staging → creates version bump PR to dev # 2. Testing from feature branch: # - Manually trigger from feature branch → builds from feature branch → creates version bump PR to dev -# 3. Custom version bump target: -# - Set bump_target_branch input → creates version bump PR to specified branch instead of dev env: # Build environment versions @@ -99,11 +97,6 @@ on: required: false type: boolean default: false - bump_target_branch: - description: "Target branch for version bump PR (default: dev). NOTE: This is where the version bump PR will be created, NOT the branch to build from. The workflow always builds from the triggering branch." - required: false - type: string - default: "dev" pull_request: types: [closed] @@ -1301,8 +1294,8 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # Checkout target branch for version bump PR (default: dev, override with bump_target_branch input) - ref: ${{ inputs.bump_target_branch || 'dev' }} + # Checkout target branch for version bump PR (always dev) + ref: "dev" - name: Setup Node.js uses: actions/setup-node@v4 @@ -1414,7 +1407,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ needs.bump-version.outputs.version }}" - TARGET_BRANCH="${{ inputs.bump_target_branch || 'dev' }}" + TARGET_BRANCH="dev" # Add timestamp to branch name to avoid collisions TIMESTAMP=$(date +%s%N | cut -b1-13) # Milliseconds since epoch (13 digits) BRANCH_NAME="ci/bump-mobile-version-${VERSION}-${TIMESTAMP}" @@ -1490,8 +1483,8 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # Checkout target branch for tagging (usually dev) - ref: ${{ inputs.bump_target_branch || 'dev' }} + # Checkout target branch for tagging (always dev) + ref: "dev" token: ${{ secrets.GITHUB_TOKEN }} - name: Configure Git From 6c420c2b769b25f4ffb59ce3832a4a8c218d4e05 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Wed, 7 Jan 2026 10:28:09 -0800 Subject: [PATCH 11/47] Refine mobile SDK constants re-exports (#1551) * Clarify constants re-exports * Adjust constants export mapping * agent feedback. sort all the tings --- circuits/package.json | 2 +- contracts/package.json | 6 +- package.json | 2 +- .../mobile-sdk-alpha/src/constants/index.ts | 58 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 packages/mobile-sdk-alpha/src/constants/index.ts diff --git a/circuits/package.json b/circuits/package.json index a685fb730..b705547bc 100644 --- a/circuits/package.json +++ b/circuits/package.json @@ -5,13 +5,13 @@ "license": "MIT", "author": "self team", "scripts": { + "build:deps": "yarn workspaces foreach --from @selfxyz/circuits --topological-dev --recursive run build", "build-all": "bash scripts/build/build_register_circuits.sh && bash scripts/build/build_register_circuits_id.sh && bash scripts/build/build_register_aadhaar.sh && bash scripts/build/build_dsc_circuits.sh && bash scripts/build/build_disclose_circuits.sh", "build-disclose": "bash scripts/build/build_disclose_circuits.sh", "build-dsc": "bash scripts/build/build_dsc_circuits.sh", "build-gcp-jwt-verifier": "bash scripts/build/build_gcp_jwt_verifier.sh", "build-register": "bash scripts/build/build_register_circuits.sh", "build-register-id": "bash scripts/build/build_register_circuits_id.sh", - "build:deps": "yarn workspaces foreach --from @selfxyz/circuits --topological-dev --recursive run build", "download": "bash scripts/server/download_circuits_from_AWS.sh", "format": "prettier --write .", "install-circuits": "yarn workspaces focus @selfxyz/circuits", diff --git a/contracts/package.json b/contracts/package.json index 657bee537..9c3c0ce8d 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -70,11 +70,11 @@ "update:hub": "npx dotenv-cli -- bash -c 'NETWORK=${NETWORK} npx tsx scripts/setRegistry.ts'", "update:ofacroot": "npx dotenv-cli -- bash -c 'NETWORK=${NETWORK} npx tsx scripts/updateRegistryOfacRoot.ts'", "update:pcr0": "npx dotenv-cli -- bash -c 'PCR0_ACTION=${PCR0_ACTION:-add} PCR0_KEY=${PCR0_KEY} yarn hardhat ignition deploy ignition/modules/scripts/updatePCR0.ts --network ${NETWORK:-localhost} --reset'", + "upgrade": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade --network ${NETWORK:-localhost}'", + "upgrade:history": "yarn hardhat upgrade:history", "upgrade:hub": "npx dotenv-cli -- bash -c 'yarn hardhat ignition deploy ignition/modules/upgrade/deployNewHubAndUpgrade.ts --network ${NETWORK:-localhost} ${VERIFY:+--verify}'", "upgrade:registry": "npx dotenv-cli -- bash -c 'yarn hardhat ignition deploy ignition/modules/upgrade/deployNewRegistryAndUpgrade.ts --network ${NETWORK:-localhost} ${VERIFY:+--verify}'", - "upgrade": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade --network ${NETWORK:-localhost}'", - "upgrade:status": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade:status --network ${NETWORK:-localhost}'", - "upgrade:history": "yarn hardhat upgrade:history" + "upgrade:status": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade:status --network ${NETWORK:-localhost}'" }, "dependencies": { "@ashpect/smt": "https://github.com/ashpect/smt#main", diff --git a/package.json b/package.json index 8c8e06dce..2d0b2ee8d 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "build:demo": "yarn workspace mobile-sdk-demo build", "build:mobile-sdk": "yarn workspace @selfxyz/mobile-sdk-alpha build", "check:versions": "node scripts/check-package-versions.mjs", + "demo:mobile": "yarn build:mobile-sdk && yarn build:demo && yarn workspace mobile-sdk-demo start", "docstrings": "yarn docstrings:app && yarn docstrings:sdk", "docstrings:app": "yarn tsx scripts/docstring-report.ts \"app/src/**/*.{ts,tsx}\" --label \"Mobile App\" --write-report docs/coverage/app.json", "docstrings:sdk": "yarn tsx scripts/docstring-report.ts \"packages/mobile-sdk-alpha/src/**/*.{ts,tsx}\" --label \"Mobile SDK Alpha\" --write-report docs/coverage/sdk.json", - "demo:mobile": "yarn build:mobile-sdk && yarn build:demo && yarn workspace mobile-sdk-demo start", "format": "SKIP_BUILD_DEPS=1 yarn format:root && yarn format:github && SKIP_BUILD_DEPS=1 yarn workspaces foreach --parallel -i --all --exclude self-workspace-root run format", "format:github": "yarn prettier --parser yaml --write .github/**/*.yml --single-quote false", "format:root": "echo 'format markdown' && yarn prettier --parser markdown --write *.md && echo 'format yaml' && yarn prettier --parser yaml --write .*.{yml,yaml} --single-quote false && yarn prettier --write scripts/**/*.{js,mjs,ts} && yarn prettier --parser json --write scripts/**/*.json", diff --git a/packages/mobile-sdk-alpha/src/constants/index.ts b/packages/mobile-sdk-alpha/src/constants/index.ts new file mode 100644 index 000000000..e89f90b86 --- /dev/null +++ b/packages/mobile-sdk-alpha/src/constants/index.ts @@ -0,0 +1,58 @@ +// 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. + +export { + AadhaarEvents, + AppEvents, + AuthEvents, + BackupEvents, + DocumentEvents, + MockDataEvents, + NotificationEvents, + PassportEvents, + PointEvents, + ProofEvents, +} from './analytics'; + +export { NFC_IMAGE } from './images'; + +export { advercase, dinot, dinotBold, plexMono } from './fonts'; +export { + amber50, + amber500, + black, + blue100, + blue600, + blue700, + borderColor, + charcoal, + cyan300, + emerald500, + green500, + neutral400, + neutral700, + red500, + separatorColor, + sky500, + slate100, + slate200, + slate300, + slate400, + slate50, + slate500, + slate600, + slate700, + slate800, + slate900, + teal300, + teal500, + textBlack, + white, + yellow500, + zinc400, + zinc500, + zinc800, + zinc900, +} from './colors'; +export { extraYPadding } from './layout'; From 61c9d56456b9024ee15024515da0c3361c0908f5 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Wed, 7 Jan 2026 11:06:04 -0800 Subject: [PATCH 12/47] Update fastlane local build flow (#1552) --- app/Gemfile | 2 +- app/Gemfile.lock | 29 ++++++++++++++++--------- app/fastlane/Fastfile | 28 +++++++++++------------- app/fastlane/helpers/version_manager.rb | 14 ++++++++++++ app/scripts/mobile-deploy-confirm.cjs | 8 +++++++ 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/app/Gemfile b/app/Gemfile index b5b13667f..7e41c8b9c 100644 --- a/app/Gemfile +++ b/app/Gemfile @@ -8,7 +8,7 @@ gem "cocoapods", ">= 1.13", "!= 1.15.0", "!= 1.15.1" gem "activesupport", ">= 6.1.7.5", "!= 7.1.0" # Add fastlane for CI/CD -gem "fastlane", "~> 2.228.0" +gem "fastlane", "~> 2.230.0" group :development do gem "dotenv" diff --git a/app/Gemfile.lock b/app/Gemfile.lock index ef2feddd2..39d601d6f 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -2,6 +2,7 @@ GEM remote: https://rubygems.org/ specs: CFPropertyList (3.0.8) + abbrev (0.1.2) activesupport (7.2.3) base64 benchmark (>= 0.3) @@ -22,8 +23,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1198.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1200.0) + aws-sdk-core (3.241.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -31,17 +32,17 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.119.0) + aws-sdk-core (~> 3, >= 3.241.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.209.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.210.1) + aws-sdk-core (~> 3, >= 3.241.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.3.0) + base64 (0.2.0) benchmark (0.5.0) bigdecimal (4.0.1) claide (1.1.0) @@ -88,6 +89,7 @@ GEM highline (~> 2.0.0) concurrent-ruby (1.3.6) connection_pool (3.0.2) + csv (3.3.5) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) @@ -128,15 +130,18 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.228.0) + fastlane (2.230.0) CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) bundler (>= 1.12.0, < 3.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -154,9 +159,12 @@ GEM http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) naturally (~> 2.2) + nkf (~> 0.2.0) optparse (>= 0.1.1, < 1.0.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) @@ -232,13 +240,14 @@ GEM minitest (6.0.0) prism (~> 1.5) molinillo (0.8.0) - multi_json (1.18.0) + multi_json (1.19.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) nap (1.1.0) naturally (2.3.0) netrc (0.11.0) + nkf (0.2.0) nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) @@ -304,7 +313,7 @@ DEPENDENCIES activesupport (>= 6.1.7.5, != 7.1.0) cocoapods (>= 1.13, != 1.15.1, != 1.15.0) dotenv - fastlane (~> 2.228.0) + fastlane (~> 2.230.0) fastlane-plugin-increment_version_code fastlane-plugin-versioning_android nokogiri (~> 1.18) diff --git a/app/fastlane/Fastfile b/app/fastlane/Fastfile index 3101b9ee4..a0d0913e0 100644 --- a/app/fastlane/Fastfile +++ b/app/fastlane/Fastfile @@ -172,6 +172,10 @@ platform :ios do Fastlane::Helpers.verify_env_vars(required_env_vars) + if local_development && version_bump != "skip" + Fastlane::Helpers.bump_local_build_number("ios") + end + # Read build number from version.json (already set by CI or local version-manager.cjs) build_number = Fastlane::Helpers.get_ios_build_number UI.message("📦 Using iOS build number: #{build_number}") @@ -310,18 +314,14 @@ platform :android do # Uploads must be done by CI/CD machines with proper authentication if local_development && !skip_upload skip_upload = true - UI.important("🏠 LOCAL DEVELOPMENT: Automatically skipping Play Store upload") - UI.important(" Uploads require CI/CD machine permissions and will be handled automatically") + UI.important("🏠 LOCAL DEVELOPMENT: Play Store uploads are disabled") + UI.important(" Upload the AAB manually in the Play Console after the build finishes") end if local_development if ENV["ANDROID_KEYSTORE_PATH"].nil? ENV["ANDROID_KEYSTORE_PATH"] = Fastlane::Helpers.android_create_keystore(android_keystore_path) end - - if ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"].nil? - ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"] = Fastlane::Helpers.android_create_play_store_key(android_play_store_json_key_path) - end end required_env_vars = [ @@ -332,11 +332,13 @@ platform :android do "ANDROID_KEY_PASSWORD", "ANDROID_PACKAGE_NAME", ] - # Only require JSON key path when not running in CI (local development) - required_env_vars << "ANDROID_PLAY_STORE_JSON_KEY_PATH" if local_development Fastlane::Helpers.verify_env_vars(required_env_vars) + if local_development && version_bump != "skip" + Fastlane::Helpers.bump_local_build_number("android") + end + # Read version code from version.json (already set by CI or local version-manager.cjs) version_code = Fastlane::Helpers.get_android_build_number UI.message("📦 Using Android build number: #{version_code}") @@ -353,13 +355,6 @@ platform :android do target_platform = options[:track] == "production" ? "Google Play" : "Internal Testing" should_upload = Fastlane::Helpers.should_upload_app(target_platform) - # Validate JSON key only in local development; CI uses Workload Identity Federation (ADC) - if local_development - validate_play_store_json_key( - json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"], - ) - end - Fastlane::Helpers.with_retry(max_retries: 3, delay: 10) do gradle( task: "clean bundleRelease --stacktrace --info", @@ -376,6 +371,9 @@ platform :android do if test_mode || skip_upload if skip_upload UI.important("🔨 BUILD ONLY: Skipping Play Store upload") + if local_development + UI.important("📦 Manual upload required: #{android_aab_path}") + end else UI.important("🧪 TEST MODE: Skipping Play Store upload") end diff --git a/app/fastlane/helpers/version_manager.rb b/app/fastlane/helpers/version_manager.rb index 3bb97a6cb..352651611 100644 --- a/app/fastlane/helpers/version_manager.rb +++ b/app/fastlane/helpers/version_manager.rb @@ -44,6 +44,20 @@ module Fastlane data["android"]["build"] end + def bump_local_build_number(platform) + unless %w[ios android].include?(platform) + UI.user_error!("Invalid platform: #{platform}. Must be 'ios' or 'android'") + end + + data = read_version_file + data[platform]["build"] += 1 + + write_version_file(data) + UI.success("Bumped #{platform} build number to #{data[platform]["build"]}") + + data[platform]["build"] + end + def verify_ci_version_match # Verify that versions were pre-set by CI unless ENV["CI_VERSION"] && ENV["CI_IOS_BUILD"] && ENV["CI_ANDROID_BUILD"] diff --git a/app/scripts/mobile-deploy-confirm.cjs b/app/scripts/mobile-deploy-confirm.cjs index fe9bccf51..90edc45e2 100755 --- a/app/scripts/mobile-deploy-confirm.cjs +++ b/app/scripts/mobile-deploy-confirm.cjs @@ -441,6 +441,14 @@ function displayWarningsAndGitStatus() { function displayFullConfirmation(platform, versions, deploymentMethod) { displayDeploymentHeader(platform); displayDeploymentMethod(deploymentMethod); + if ( + deploymentMethod === DEPLOYMENT_METHODS.LOCAL_FASTLANE && + (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) + ) { + console.log( + `${CONSOLE_SYMBOLS.WARNING} Local Android uploads are disabled. You'll need to manually upload the AAB in Play Console.`, + ); + } displayPlatformVersions(platform, versions); displayWarningsAndGitStatus(); } From 548779df2e7d2915b7ad66f8332ca23a55febd20 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Wed, 7 Jan 2026 11:06:35 -0800 Subject: [PATCH 13/47] SELF-810: update react native edge to edge pkg (#1553) * update edge to edge for play store req * address metrojs missing peer dep --- app/metro.config.cjs | 1 + app/package.json | 2 +- yarn.lock | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/metro.config.cjs b/app/metro.config.cjs index 9a94c7411..24797c027 100644 --- a/app/metro.config.cjs +++ b/app/metro.config.cjs @@ -409,6 +409,7 @@ const config = { 'react-native-reanimated', '@react-native-masked-view/masked-view', '@react-native-firebase/analytics', + 'react-native-b4a', ]; if (optionalPeerDependencies.includes(moduleName)) { diff --git a/app/package.json b/app/package.json index 26ad5c742..f11c43bd8 100644 --- a/app/package.json +++ b/app/package.json @@ -148,7 +148,7 @@ "react-native-cloud-storage": "^2.2.2", "react-native-device-info": "^14.0.4", "react-native-dotenv": "^3.4.11", - "react-native-edge-to-edge": "^1.6.2", + "react-native-edge-to-edge": "^1.7.0", "react-native-gesture-handler": "2.19.0", "react-native-get-random-values": "^1.11.0", "react-native-haptic-feedback": "^2.3.3", diff --git a/yarn.lock b/yarn.lock index acf20c195..d9e270a75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8657,7 +8657,7 @@ __metadata: react-native-cloud-storage: "npm:^2.2.2" react-native-device-info: "npm:^14.0.4" react-native-dotenv: "npm:^3.4.11" - react-native-edge-to-edge: "npm:^1.6.2" + react-native-edge-to-edge: "npm:^1.7.0" react-native-gesture-handler: "npm:2.19.0" react-native-get-random-values: "npm:^1.11.0" react-native-haptic-feedback: "npm:^2.3.3" @@ -29819,7 +29819,7 @@ __metadata: languageName: node linkType: hard -"react-native-edge-to-edge@npm:^1.6.2": +"react-native-edge-to-edge@npm:^1.7.0": version: 1.7.0 resolution: "react-native-edge-to-edge@npm:1.7.0" peerDependencies: From 906ca505d36486aa322233b291f5306091635cd2 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Wed, 7 Jan 2026 11:23:09 -0800 Subject: [PATCH 14/47] chore: bump mobile version to 2.9.10 (#1554) * update gems and pods * bump build number and version after 2.9.9 release --- app/Gemfile.lock | 6 +++--- app/android/app/build.gradle | 2 +- app/ios/OpenPassport/Info.plist | 2 +- app/ios/Podfile.lock | 14 ++++++++++---- app/ios/Self.xcodeproj/project.pbxproj | 4 ++-- app/package.json | 2 +- app/version.json | 4 ++-- 7 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 39d601d6f..92d6055e2 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -182,7 +182,7 @@ GEM fastlane-plugin-versioning_android (0.1.1) fastlane-sirp (1.0.0) sysrandom (~> 1.0) - ffi (1.17.2) + ffi (1.17.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) @@ -237,7 +237,7 @@ GEM mini_magick (4.13.2) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.0) + minitest (6.0.1) prism (~> 1.5) molinillo (0.8.0) multi_json (1.19.1) @@ -248,7 +248,7 @@ GEM naturally (2.3.0) netrc (0.11.0) nkf (0.2.0) - nokogiri (1.18.10) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) optparse (0.8.1) diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index a9398ed55..f1c4e75c6 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -135,7 +135,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 121 - versionName "2.9.8" + versionName "2.9.10" manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp'] externalNativeBuild { cmake { diff --git a/app/ios/OpenPassport/Info.plist b/app/ios/OpenPassport/Info.plist index 52e6e6f62..0aee45559 100644 --- a/app/ios/OpenPassport/Info.plist +++ b/app/ios/OpenPassport/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.9.8 + 2.9.10 CFBundleSignature ???? CFBundleURLTypes diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 5d967b466..54a7d7609 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -6,6 +6,8 @@ PODS: - AppAuth/ExternalUserAgent (2.0.0): - AppAuth/Core - boost (1.84.0) + - BVLinearGradient (2.8.3): + - React-Core - DoubleConversion (1.1.6) - fast_float (6.1.4) - FBLazyVector (0.76.9) @@ -1512,7 +1514,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-compat (2.23.0): + - react-native-compat (2.23.1): - DoubleConversion - glog - hermes-engine @@ -2174,7 +2176,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - segment-analytics-react-native (2.21.3): + - segment-analytics-react-native (2.21.4): - React-Core - sovran-react-native - Sentry/HybridSDK (8.53.2) @@ -2187,6 +2189,7 @@ PODS: DEPENDENCIES: - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) + - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) @@ -2323,6 +2326,8 @@ SPEC REPOS: EXTERNAL SOURCES: boost: :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" + BVLinearGradient: + :path: "../node_modules/react-native-linear-gradient" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" fast_float: @@ -2525,6 +2530,7 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 boost: 1dca942403ed9342f98334bf4c3621f011aa7946 + BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 @@ -2587,7 +2593,7 @@ SPEC CHECKSUMS: react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d react-native-blur: 6334d934a9b5e67718b8f5725c44cc0a12946009 react-native-cloud-storage: 8d89f2bc574cf11068dfd90933905974087fb9e9 - react-native-compat: 44e82a19b6130e3965d6c8ff37dbc1546d477f0f + react-native-compat: b80530ebcd3d574be5dd99cb27b984a17c119abc react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-nfc-manager: 66a00e5ddab9704efebe19d605b1b8afb0bb1bd7 @@ -2636,7 +2642,7 @@ SPEC CHECKSUMS: RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8 RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0 RNSVG: e1cf5a9a5aa12c69f2ec47031defbd87ae7fb697 - segment-analytics-react-native: a0c29c75ede1989118b50cac96b9495ea5c91a1d + segment-analytics-react-native: 0eae155b0e9fa560fa6b17d78941df64537c35b7 Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594 diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index 93884b404..6ee7b5d90 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -546,7 +546,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.8; + MARKETING_VERSION = 2.9.10; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -686,7 +686,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.8; + MARKETING_VERSION = 2.9.10; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/package.json b/app/package.json index f11c43bd8..524d5c329 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@selfxyz/mobile-app", - "version": "2.9.8", + "version": "2.9.10", "private": true, "type": "module", "scripts": { diff --git a/app/version.json b/app/version.json index 49a9dfead..5477e32f9 100644 --- a/app/version.json +++ b/app/version.json @@ -4,7 +4,7 @@ "lastDeployed": "2026-01-03T23:45:02.007Z" }, "android": { - "build": 129, - "lastDeployed": "2026-01-03T23:45:02.007Z" + "build": 130, + "lastDeployed": "2026-01-07T19:05:43Z" } } From 6e01b3c1144aba7ef2c4561d6f4dbb5e39f177ec Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Wed, 7 Jan 2026 15:11:34 -0800 Subject: [PATCH 15/47] fix scrollview bottom padding (#1556) --- app/src/screens/dev/DevSettingsScreen.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index 143a90b8b..d40e48c4f 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -255,7 +255,10 @@ const ScreenSelector = ({}) => { /> - + {screenList.map(item => ( Date: Fri, 9 Jan 2026 00:44:24 +0530 Subject: [PATCH 16/47] Add skipReselect Option (#1561) * Add option to NFCMethodSelectionScreen * update types * update types --- app/src/hooks/useFeedbackAutoHide.ts | 8 +++- app/src/hooks/useFeedbackModal.ts | 47 +++++++++++++------ app/src/integrations/nfc/nfcScanner.ts | 2 + app/src/integrations/nfc/passportReader.ts | 5 ++ .../DocumentNFCMethodSelectionScreen.tsx | 7 +++ .../scanning/DocumentNFCScanScreen.tsx | 12 ++++- app/src/types/reactNativePassportReader.d.ts | 1 + 7 files changed, 65 insertions(+), 17 deletions(-) diff --git a/app/src/hooks/useFeedbackAutoHide.ts b/app/src/hooks/useFeedbackAutoHide.ts index 908e8a1c5..a86bf118a 100644 --- a/app/src/hooks/useFeedbackAutoHide.ts +++ b/app/src/hooks/useFeedbackAutoHide.ts @@ -17,7 +17,13 @@ export const useFeedbackAutoHide = () => { // When screen goes out of focus, hide the feedback button return () => { - hideFeedbackButton(); + try { + hideFeedbackButton(); + } catch (error) { + if (__DEV__) { + console.debug('Failed to hide feedback button:', error); + } + } }; }, []), ); diff --git a/app/src/hooks/useFeedbackModal.ts b/app/src/hooks/useFeedbackModal.ts index 02b10dbc6..a2df0da32 100644 --- a/app/src/hooks/useFeedbackModal.ts +++ b/app/src/hooks/useFeedbackModal.ts @@ -27,25 +27,38 @@ export const useFeedbackModal = () => { timeoutRef.current = null; } - switch (type) { - case 'button': - showFeedbackButton(); - break; - case 'widget': - showFeedbackWidget(); - break; - case 'custom': - setIsVisible(true); - break; - default: - showFeedbackButton(); + try { + switch (type) { + case 'button': + showFeedbackButton(); + break; + case 'widget': + showFeedbackWidget(); + break; + case 'custom': + setIsVisible(true); + break; + default: + showFeedbackButton(); + } + } catch (error) { + if (__DEV__) { + console.debug('Failed to show feedback button/widget:', error); + } + setIsVisible(true); } // we can close the feedback modals(sentry and custom modals), but can't do so for the Feedback button. // This hides the button after 10 seconds. if (type === 'button') { timeoutRef.current = setTimeout(() => { - hideFeedbackButton(); + try { + hideFeedbackButton(); + } catch (error) { + if (__DEV__) { + console.debug('Failed to hide feedback button:', error); + } + } timeoutRef.current = null; }, 10000); } @@ -57,7 +70,13 @@ export const useFeedbackModal = () => { timeoutRef.current = null; } - hideFeedbackButton(); + try { + hideFeedbackButton(); + } catch (error) { + if (__DEV__) { + console.debug('Failed to hide feedback button:', error); + } + } setIsVisible(false); }, []); diff --git a/app/src/integrations/nfc/nfcScanner.ts b/app/src/integrations/nfc/nfcScanner.ts index 2cfdb245a..e356d5392 100644 --- a/app/src/integrations/nfc/nfcScanner.ts +++ b/app/src/integrations/nfc/nfcScanner.ts @@ -27,6 +27,7 @@ interface Inputs { usePacePolling?: boolean; sessionId: string; userId?: string; + skipReselect?: boolean; } interface DataGroupHash { @@ -91,6 +92,7 @@ const scanAndroid = async ( canNumber: inputs.canNumber ?? '', useCan: inputs.useCan ?? false, sessionId: inputs.sessionId, + skipReselect: inputs.skipReselect ?? false, }); }; diff --git a/app/src/integrations/nfc/passportReader.ts b/app/src/integrations/nfc/passportReader.ts index 8449a6e94..d78733414 100644 --- a/app/src/integrations/nfc/passportReader.ts +++ b/app/src/integrations/nfc/passportReader.ts @@ -16,6 +16,7 @@ type ScanOptions = { usePacePolling?: boolean; sessionId?: string; quality?: number; + skipReselect?: boolean; }; export interface AndroidScanResponse { @@ -91,6 +92,8 @@ if (Platform.OS === 'android') { canNumber = '', useCan = false, quality = 1, + skipReselect = false, + sessionId, } = options; return androidScan({ @@ -100,6 +103,8 @@ if (Platform.OS === 'android') { canNumber, useCan, quality, + skipReselect, + sessionId, }); }; } diff --git a/app/src/screens/documents/scanning/DocumentNFCMethodSelectionScreen.tsx b/app/src/screens/documents/scanning/DocumentNFCMethodSelectionScreen.tsx index fa73a9cbe..317992b4f 100644 --- a/app/src/screens/documents/scanning/DocumentNFCMethodSelectionScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentNFCMethodSelectionScreen.tsx @@ -39,6 +39,13 @@ const NFC_METHODS = [ platform: ['ios'], params: {}, }, + { + key: 'skipReselect', + label: 'Skip Re-select', + description: 'Skip the re-select step after the NFC scan.', + platform: ['android'], + params: { skipReselect: true }, + }, { key: 'usePacePolling', label: 'Use PACE Polling', diff --git a/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx b/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx index c092573ff..7b5b3cb47 100644 --- a/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx @@ -81,6 +81,7 @@ const emitter = : null; type DocumentNFCScanRouteParams = { + skipReselect?: boolean; usePacePolling?: boolean; canNumber?: string; useCan?: boolean; @@ -326,8 +327,14 @@ const DocumentNFCScanScreen: React.FC = () => { }, 30000); try { - const { canNumber, useCan, skipPACE, skipCA, extendedMode } = - route.params ?? {}; + const { + canNumber, + useCan, + skipPACE, + skipCA, + extendedMode, + skipReselect, + } = route.params ?? {}; await configureNfcAnalytics(); const scanResponse = await scan({ @@ -341,6 +348,7 @@ const DocumentNFCScanScreen: React.FC = () => { extendedMode, usePacePolling: isPacePolling, sessionId: sessionIdRef.current, + skipReselect, }); // Check if scan was cancelled by timeout diff --git a/app/src/types/reactNativePassportReader.d.ts b/app/src/types/reactNativePassportReader.d.ts index df5d16167..7077eb5ef 100644 --- a/app/src/types/reactNativePassportReader.d.ts +++ b/app/src/types/reactNativePassportReader.d.ts @@ -11,6 +11,7 @@ declare module 'react-native-passport-reader' { useCan: boolean; quality?: number; sessionId?: string; + skipReselect?: boolean; } interface PassportReader { From 1e44dc9c8d7f130caf66295d6af6160194b5d59c Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Fri, 9 Jan 2026 12:49:49 -0800 Subject: [PATCH 17/47] Home: replace loading text with centered spinner (#1566) * Replace home loading text with spinner * update home screen spinner --- app/Gemfile.lock | 12 ++++++------ app/src/screens/home/HomeScreen.tsx | 12 ++++++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 92d6055e2..1b67015b5 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -23,8 +23,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1200.0) - aws-sdk-core (3.241.1) + aws-partitions (1.1201.0) + aws-sdk-core (3.241.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -32,11 +32,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.119.0) - aws-sdk-core (~> 3, >= 3.241.0) + aws-sdk-kms (1.120.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.210.1) - aws-sdk-core (~> 3, >= 3.241.0) + aws-sdk-s3 (1.211.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) diff --git a/app/src/screens/home/HomeScreen.tsx b/app/src/screens/home/HomeScreen.tsx index 151da5f55..a347595a0 100644 --- a/app/src/screens/home/HomeScreen.tsx +++ b/app/src/screens/home/HomeScreen.tsx @@ -10,7 +10,15 @@ import React, { useState, } from 'react'; import { Dimensions, Image, Pressable } from 'react-native'; -import { Button, ScrollView, Text, View, XStack, YStack } from 'tamagui'; +import { + Button, + ScrollView, + Spinner, + Text, + View, + XStack, + YStack, +} from 'tamagui'; import { useFocusEffect, useIsFocused, @@ -201,7 +209,7 @@ const HomeScreen: React.FC = () => { justifyContent="center" alignItems="center" > - Loading documents... +
); } From 850e3b98f9d5022c8fc88f7e1a42d7bebcba01bb Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Fri, 9 Jan 2026 13:56:10 -0800 Subject: [PATCH 18/47] SELF-1754: Implement selective disclosure on Proving Screen (#1549) * add document selector test screen * clean up mock docs * update selection options * Add DocumentSelectorForProving screen and route proof flows through it (#1555) * Add document selector to proving flow * fix formatting * improvements * redirect user to document not found screen when no documents * option flow tweaks and tests * wip tweaks * fix scrollview bottom padding (#1556) * tighten up selection text * create inerstitial * save wip * remove not accepted state * save wip design * formatting * update design * update layout * Update proving flow tests (#1559) * Refactor ProveScreen to ProofRequestCard layout and preserve scroll position (#1560) * Refactor prove screen layout * fix: amount of hooks rendered needs to be the same for all variants * long URL ellipsis * keep titles consistent * lint --------- Co-authored-by: Leszek Stachowski * wip fix tests * fix tests * formatting * agent feedback * fix tests * save wip * remove text * fix types * save working header update * no transition * cache document load for proving flow * save fixes * small fixes * match disclosure text * design updates * fix approve flow * fix document type flash * add min height so text doesn't jump * update lock * formatting * save refactor wip * don't enable euclid yet * fix tests * fix staleness check * fix select box description * remove id selector screen * vertically center * button updates * Remove proving document cache (#1567) * formatting --------- Co-authored-by: Leszek Stachowski --- app/jest.setup.js | 51 +- app/src/components/Disclosures.tsx | 75 +-- .../components/documents/IDSelectorItem.tsx | 132 +++++ .../components/documents/IDSelectorSheet.tsx | 174 ++++++ app/src/components/documents/index.ts | 18 + app/src/components/navbar/BaseNavBar.tsx | 12 +- app/src/components/navbar/DefaultNavBar.tsx | 12 +- app/src/components/navbar/HomeNavBar.tsx | 2 +- .../proof-request/BottomActionBar.tsx | 170 ++++++ .../proof-request/BottomVerifyBar.tsx | 49 ++ .../proof-request/ConnectedWalletBadge.tsx | 103 ++++ .../proof-request/DisclosureItem.tsx | 85 +++ .../proof-request/ProofMetadataBar.tsx | 89 ++++ .../proof-request/ProofRequestCard.tsx | 138 +++++ .../proof-request/ProofRequestHeader.tsx | 104 ++++ .../proof-request/WalletAddressModal.tsx | 240 +++++++++ .../components/proof-request/designTokens.ts | 40 ++ app/src/components/proof-request/icons.tsx | 143 +++++ app/src/components/proof-request/index.ts | 68 +++ app/src/hooks/useEarnPointsFlow.ts | 2 +- app/src/hooks/useSelfAppData.ts | 62 +++ app/src/hooks/useSelfAppStalenessCheck.ts | 40 ++ app/src/navigation/account.ts | 13 + app/src/navigation/deeplinks.ts | 7 +- app/src/navigation/index.tsx | 15 +- app/src/navigation/verification.ts | 38 +- .../account/settings/ProofSettingsScreen.tsx | 130 +++++ .../account/settings/SettingsScreen.tsx | 4 +- .../DocumentSelectorForProvingScreen.tsx | 495 ++++++++++++++++++ app/src/screens/verification/ProveScreen.tsx | 394 +++++--------- .../verification/ProvingScreenRouter.tsx | 205 ++++++++ .../verification/QRCodeViewFinderScreen.tsx | 14 +- app/src/stores/settingStore.ts | 12 + app/src/utils/devUtils.ts | 2 +- app/src/utils/disclosureUtils.ts | 89 ++++ app/src/utils/documentUtils.ts | 19 + app/src/utils/index.ts | 3 + app/tests/__setup__/mocks/navigation.js | 17 +- app/tests/__setup__/mocks/ui.js | 12 + .../documents/IDSelectorSheet.test.tsx | 225 ++++++++ app/tests/src/hooks/useEarnPointsFlow.test.ts | 2 +- app/tests/src/navigation.test.tsx | 3 + app/tests/src/navigation/deeplinks.test.ts | 4 +- .../DocumentSelectorForProvingScreen.test.tsx | 468 +++++++++++++++++ .../verification/ProvingScreenRouter.test.tsx | 321 ++++++++++++ .../buttons/HeldPrimaryButtonProveScreen.tsx | 2 +- .../mobile-sdk-alpha/src/constants/colors.ts | 3 + .../mobile-sdk-alpha/src/constants/index.ts | 2 + .../src/documents/validation.ts | 190 +++++++ packages/mobile-sdk-alpha/src/index.ts | 15 +- .../tests/documents/validation.test.ts | 314 +++++++++++ 51 files changed, 4446 insertions(+), 381 deletions(-) create mode 100644 app/src/components/documents/IDSelectorItem.tsx create mode 100644 app/src/components/documents/IDSelectorSheet.tsx create mode 100644 app/src/components/documents/index.ts create mode 100644 app/src/components/proof-request/BottomActionBar.tsx create mode 100644 app/src/components/proof-request/BottomVerifyBar.tsx create mode 100644 app/src/components/proof-request/ConnectedWalletBadge.tsx create mode 100644 app/src/components/proof-request/DisclosureItem.tsx create mode 100644 app/src/components/proof-request/ProofMetadataBar.tsx create mode 100644 app/src/components/proof-request/ProofRequestCard.tsx create mode 100644 app/src/components/proof-request/ProofRequestHeader.tsx create mode 100644 app/src/components/proof-request/WalletAddressModal.tsx create mode 100644 app/src/components/proof-request/designTokens.ts create mode 100644 app/src/components/proof-request/icons.tsx create mode 100644 app/src/components/proof-request/index.ts create mode 100644 app/src/hooks/useSelfAppData.ts create mode 100644 app/src/hooks/useSelfAppStalenessCheck.ts create mode 100644 app/src/screens/account/settings/ProofSettingsScreen.tsx create mode 100644 app/src/screens/verification/DocumentSelectorForProvingScreen.tsx create mode 100644 app/src/screens/verification/ProvingScreenRouter.tsx create mode 100644 app/src/utils/disclosureUtils.ts create mode 100644 app/src/utils/documentUtils.ts create mode 100644 app/tests/src/components/documents/IDSelectorSheet.test.tsx create mode 100644 app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx create mode 100644 app/tests/src/screens/verification/ProvingScreenRouter.test.tsx create mode 100644 packages/mobile-sdk-alpha/src/documents/validation.ts create mode 100644 packages/mobile-sdk-alpha/tests/documents/validation.test.ts diff --git a/app/jest.setup.js b/app/jest.setup.js index 1d0275e04..1dc286223 100644 --- a/app/jest.setup.js +++ b/app/jest.setup.js @@ -100,6 +100,7 @@ jest.mock('react-native', () => { get NativeModules() { return global.NativeModules || {}; }, + useColorScheme: jest.fn(() => 'light'), NativeEventEmitter: jest.fn().mockImplementation(nativeModule => { return { addListener: jest.fn(), @@ -110,10 +111,15 @@ jest.mock('react-native', () => { }), PixelRatio: mockPixelRatio, Dimensions: { - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), }, Linking: { getInitialURL: jest.fn().mockResolvedValue(null), @@ -139,6 +145,7 @@ jest.mock('react-native', () => { ScrollView: 'ScrollView', TouchableOpacity: 'TouchableOpacity', TouchableHighlight: 'TouchableHighlight', + Pressable: 'Pressable', Image: 'Image', ActivityIndicator: 'ActivityIndicator', SafeAreaView: 'SafeAreaView', @@ -273,10 +280,15 @@ jest.mock( Version: 14, }, Dimensions: { - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), }, StyleSheet: { create: jest.fn(styles => styles), @@ -359,15 +371,18 @@ jest.mock( '../packages/mobile-sdk-alpha/node_modules/react-native/Libraries/Utilities/Dimensions', () => ({ getConstants: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, })), set: jest.fn(), - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), - addEventListener: jest.fn(), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), removeEventListener: jest.fn(), }), { virtual: true }, @@ -550,8 +565,14 @@ jest.mock( { virtual: true }, ); +// Mock the hooks subpath from mobile-sdk-alpha +jest.mock('@selfxyz/mobile-sdk-alpha/hooks', () => ({ + useSafeBottomPadding: jest.fn((basePadding = 20) => basePadding + 50), +})); + // Mock problematic mobile-sdk-alpha components that use React Native StyleSheet jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + // Override only the specific mocks we need NFCScannerScreen: jest.fn(() => null), SelfClientProvider: jest.fn(({ children }) => children), useSelfClient: jest.fn(() => { diff --git a/app/src/components/Disclosures.tsx b/app/src/components/Disclosures.tsx index 8770f020e..b95243a75 100644 --- a/app/src/components/Disclosures.tsx +++ b/app/src/components/Disclosures.tsx @@ -5,45 +5,24 @@ import React from 'react'; import { XStack, YStack } from 'tamagui'; -import type { Country3LetterCode } from '@selfxyz/common/constants'; -import { countryCodes } from '@selfxyz/common/constants'; import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils'; import { BodyText } from '@selfxyz/mobile-sdk-alpha/components'; import { slate200, slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import CheckMark from '@/assets/icons/checkmark.svg'; +import { + getDisclosureText, + ORDERED_DISCLOSURE_KEYS, +} from '@/utils/disclosureUtils'; interface DisclosureProps { disclosures: SelfAppDisclosureConfig; } -function listToString(list: string[]): string { - if (list.length === 1) { - return list[0]; - } else if (list.length === 2) { - return list.join(' nor '); - } - return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`; -} - export default function Disclosures({ disclosures }: DisclosureProps) { - // Define the order in which disclosures should appear. - const ORDERED_KEYS: Array = [ - 'issuing_state', - 'name', - 'passport_number', - 'nationality', - 'date_of_birth', - 'gender', - 'expiry_date', - 'ofac', - 'excludedCountries', - 'minimumAge', - ] as const; - return ( - {ORDERED_KEYS.map(key => { + {ORDERED_DISCLOSURE_KEYS.map(key => { const isEnabled = disclosures[key]; if ( !isEnabled || @@ -52,53 +31,17 @@ export default function Disclosures({ disclosures }: DisclosureProps) { return null; } - let text = ''; - switch (key) { - case 'ofac': - text = 'I am not on the OFAC sanction list'; - break; - case 'excludedCountries': - text = `I am not a citizen of the following countries: ${countriesToSentence( - (disclosures.excludedCountries as Country3LetterCode[]) || [], - )}`; - break; - case 'minimumAge': - text = `Age is over ${disclosures.minimumAge}`; - break; - case 'name': - text = 'Name'; - break; - case 'passport_number': - text = 'Passport Number'; - break; - case 'date_of_birth': - text = 'Date of Birth'; - break; - case 'gender': - text = 'Gender'; - break; - case 'expiry_date': - text = 'Passport Expiry Date'; - break; - case 'issuing_state': - text = 'Issuing State'; - break; - case 'nationality': - text = 'Nationality'; - break; - default: - return null; + const text = getDisclosureText(key, disclosures); + if (!text) { + return null; } + return ; })} ); } -function countriesToSentence(countries: Array): string { - return listToString(countries.map(country => countryCodes[country])); -} - interface DisclosureItemProps { text: string; } diff --git a/app/src/components/documents/IDSelectorItem.tsx b/app/src/components/documents/IDSelectorItem.tsx new file mode 100644 index 000000000..297ac4186 --- /dev/null +++ b/app/src/components/documents/IDSelectorItem.tsx @@ -0,0 +1,132 @@ +// 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 { Pressable } from 'react-native'; +import { Separator, Text, View, XStack, YStack } from 'tamagui'; +import { Check } from '@tamagui/lucide-icons'; + +import { + black, + green500, + green600, + iosSeparator, + slate200, + slate300, + slate400, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +export interface IDSelectorItemProps { + documentName: string; + state: IDSelectorState; + onPress?: () => void; + disabled?: boolean; + isLastItem?: boolean; + testID?: string; +} + +export type IDSelectorState = 'active' | 'verified' | 'expired' | 'mock'; + +function getSubtitleText(state: IDSelectorState): string { + switch (state) { + case 'active': + return 'Currently active'; + case 'verified': + return 'Verified ID'; + case 'expired': + return 'Expired'; + case 'mock': + return 'Testing document'; + } +} + +function getSubtitleColor(state: IDSelectorState): string { + switch (state) { + case 'active': + return green600; + case 'verified': + return slate400; + case 'expired': + return slate400; + case 'mock': + return slate400; + } +} + +export const IDSelectorItem: React.FC = ({ + documentName, + state, + onPress, + disabled, + isLastItem, + testID, +}) => { + const isDisabled = disabled || isDisabledState(state); + const isActive = state === 'active'; + const subtitleText = getSubtitleText(state); + const subtitleColor = getSubtitleColor(state); + const textColor = isDisabled ? slate400 : black; + + // Determine circle color based on state + const circleColor = isDisabled ? slate200 : slate300; + + return ( + <> + + + {/* Radio button indicator */} + + + {isActive && } + + + + {/* Document info */} + + + {documentName} + + + {subtitleText} + + + + + {!isLastItem && } + + ); +}; + +export function isDisabledState(state: IDSelectorState): boolean { + return state === 'expired'; +} diff --git a/app/src/components/documents/IDSelectorSheet.tsx b/app/src/components/documents/IDSelectorSheet.tsx new file mode 100644 index 000000000..152f811e9 --- /dev/null +++ b/app/src/components/documents/IDSelectorSheet.tsx @@ -0,0 +1,174 @@ +// 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 { Button, ScrollView, Sheet, Text, View, XStack, YStack } from 'tamagui'; + +import { + black, + blue600, + slate200, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; +import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; + +import type { IDSelectorState } from '@/components/documents/IDSelectorItem'; +import { + IDSelectorItem, + isDisabledState, +} from '@/components/documents/IDSelectorItem'; + +export interface IDSelectorDocument { + id: string; + name: string; + state: IDSelectorState; +} + +export interface IDSelectorSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + documents: IDSelectorDocument[]; + selectedId?: string; + onSelect: (documentId: string) => void; + onDismiss: () => void; + onApprove: () => void; + testID?: string; +} + +export const IDSelectorSheet: React.FC = ({ + open, + onOpenChange, + documents, + selectedId, + onSelect, + onDismiss, + onApprove, + testID = 'id-selector-sheet', +}) => { + const bottomPadding = useSafeBottomPadding(16); + + // Check if the selected document is valid (not expired or unregistered) + const selectedDoc = documents.find(d => d.id === selectedId); + const canApprove = selectedDoc && !isDisabledState(selectedDoc.state); + + return ( + + + + + {/* Header */} + + Select an ID + + + {/* Document List Container with border radius */} + + + {documents.map((doc, index) => { + const isSelected = doc.id === selectedId; + // Don't override to 'active' if the document is in a disabled state + const itemState: IDSelectorState = + isSelected && !isDisabledState(doc.state) + ? 'active' + : doc.state; + + return ( + onSelect(doc.id)} + isLastItem={index === documents.length - 1} + testID={`${testID}-item-${doc.id}`} + /> + ); + })} + + + + {/* Footer Buttons */} + + + + + + + + ); +}; diff --git a/app/src/components/documents/index.ts b/app/src/components/documents/index.ts new file mode 100644 index 000000000..e4bd90441 --- /dev/null +++ b/app/src/components/documents/index.ts @@ -0,0 +1,18 @@ +// 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. + +export type { + IDSelectorDocument, + IDSelectorSheetProps, +} from '@/components/documents/IDSelectorSheet'; + +export type { + IDSelectorItemProps, + IDSelectorState, +} from '@/components/documents/IDSelectorItem'; +export { + IDSelectorItem, + isDisabledState, +} from '@/components/documents/IDSelectorItem'; +export { IDSelectorSheet } from '@/components/documents/IDSelectorSheet'; diff --git a/app/src/components/navbar/BaseNavBar.tsx b/app/src/components/navbar/BaseNavBar.tsx index 9611b84bc..e4f7186eb 100644 --- a/app/src/components/navbar/BaseNavBar.tsx +++ b/app/src/components/navbar/BaseNavBar.tsx @@ -33,6 +33,7 @@ interface RightActionProps extends ViewProps { interface NavBarTitleProps extends TextProps { children?: React.ReactNode; size?: 'large' | undefined; + color?: string; } export const LeftAction: React.FC = ({ @@ -84,13 +85,20 @@ export const LeftAction: React.FC = ({ return {children}; }; -const NavBarTitle: React.FC = ({ children, ...props }) => { +const NavBarTitle: React.FC = ({ + children, + color, + style, + ...props +}) => { if (!children) { return null; } return typeof children === 'string' ? ( - {children} + + {children} + ) : ( children ); diff --git a/app/src/components/navbar/DefaultNavBar.tsx b/app/src/components/navbar/DefaultNavBar.tsx index 196a8f7b5..f1a10885c 100644 --- a/app/src/components/navbar/DefaultNavBar.tsx +++ b/app/src/components/navbar/DefaultNavBar.tsx @@ -18,6 +18,8 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => { const { options } = props; const headerStyle = (options.headerStyle || {}) as ViewStyle; const insets = useSafeAreaInsets(); + const headerTitleStyle = (options.headerTitleStyle || {}) as TextStyle; + return ( { paddingBottom={20} backgroundColor={headerStyle.backgroundColor as string} barStyle={ - options.headerTintColor === white || - (options.headerTitleStyle as TextStyle)?.color === white + options.headerTintColor === white || headerTitleStyle?.color === white ? 'light' : 'dark' } @@ -40,9 +41,12 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => { buttonTap(); goBack(); }} - {...(options.headerTitleStyle as ViewStyle)} + color={options.headerTintColor as string} /> - + {props.options.title} diff --git a/app/src/components/navbar/HomeNavBar.tsx b/app/src/components/navbar/HomeNavBar.tsx index 5c4c02f23..fe998873e 100644 --- a/app/src/components/navbar/HomeNavBar.tsx +++ b/app/src/components/navbar/HomeNavBar.tsx @@ -56,7 +56,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => { try { Clipboard.setString(''); } catch {} - props.navigation.navigate('Prove'); + props.navigation.navigate('ProvingScreenRouter'); } catch (error) { console.error('Error consuming token:', error); if ( diff --git a/app/src/components/proof-request/BottomActionBar.tsx b/app/src/components/proof-request/BottomActionBar.tsx new file mode 100644 index 000000000..9c5f50fc3 --- /dev/null +++ b/app/src/components/proof-request/BottomActionBar.tsx @@ -0,0 +1,170 @@ +// 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 React, { useMemo } from 'react'; +import { + ActivityIndicator, + Dimensions, + Pressable, + StyleSheet, +} from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; +import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { ChevronUpDownIcon } from '@/components/proof-request/icons'; + +export interface BottomActionBarProps { + selectedDocumentName: string; + onDocumentSelectorPress: () => void; + onApprovePress: () => void; + approveDisabled?: boolean; + approving?: boolean; + testID?: string; +} + +/** + * Bottom action bar with document selector and approve button. + * Matches Figma design 15234:9322. + */ +export const BottomActionBar: React.FC = ({ + selectedDocumentName, + onDocumentSelectorPress, + onApprovePress, + approveDisabled = false, + approving = false, + testID = 'bottom-action-bar', +}) => { + // Reduce top padding to balance with safe area bottom padding + // The safe area hook adds significant padding on small screens for system UI + const topPadding = 8; + + // Calculate dynamic bottom padding based on screen height + // Scales proportionally to better center the select box beneath the disclosure list + const { height: screenHeight } = Dimensions.get('window'); + const basePadding = 12; + + // Get safe area padding (handles small screens < 900px with extra padding) + const safeAreaPadding = useSafeBottomPadding(basePadding); + + // Dynamic padding calculation: + // - Start with safe area padding (includes base + small screen adjustment) + // - Add additional padding that scales with screen height + // - Formula: safeAreaPadding + (screenHeight - 800) * 0.12 + // - This provides base padding, safe area handling, plus 0-50px extra on larger screens + // - The multiplier (0.12) ensures smooth scaling across different screen sizes + const dynamicPadding = useMemo(() => { + const heightMultiplier = Math.max(0, (screenHeight - 800) * 0.12); + return Math.round(safeAreaPadding + heightMultiplier); + }, [screenHeight, safeAreaPadding]); + + const bottomPadding = dynamicPadding; + + return ( + + + {/* Document Selector Button */} + [ + styles.documentButton, + pressed && styles.documentButtonPressed, + ]} + testID={`${testID}-document-selector`} + > + + + {selectedDocumentName} + + + + + + + + {/* Approve Button */} + [ + styles.approveButton, + (approveDisabled || approving) && styles.approveButtonDisabled, + pressed && + !approveDisabled && + !approving && + styles.approveButtonPressed, + ]} + testID={`${testID}-approve`} + > + + {approving ? ( + + ) : ( + + Approve + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + documentButton: { + backgroundColor: proofRequestColors.white, + borderWidth: 1, + borderColor: proofRequestColors.slate200, + borderRadius: 4, + }, + documentButtonPressed: { + backgroundColor: proofRequestColors.slate100, + }, + approveButton: { + flex: 1, + backgroundColor: proofRequestColors.blue600, + borderRadius: 4, + }, + approveButtonDisabled: { + opacity: 0.5, + }, + approveButtonPressed: { + backgroundColor: proofRequestColors.blue700, + }, +}); diff --git a/app/src/components/proof-request/BottomVerifyBar.tsx b/app/src/components/proof-request/BottomVerifyBar.tsx new file mode 100644 index 000000000..3c57a162d --- /dev/null +++ b/app/src/components/proof-request/BottomVerifyBar.tsx @@ -0,0 +1,49 @@ +// 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 React from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View } from 'tamagui'; + +import { HeldPrimaryButtonProveScreen } from '@selfxyz/mobile-sdk-alpha/components'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; + +export interface BottomVerifyBarProps { + onVerify: () => void; + selectedAppSessionId: string | undefined | null; + hasScrolledToBottom: boolean; + isReadyToProve: boolean; + isDocumentExpired: boolean; + testID?: string; +} + +export const BottomVerifyBar: React.FC = ({ + onVerify, + selectedAppSessionId, + hasScrolledToBottom, + isReadyToProve, + isDocumentExpired, + testID = 'bottom-verify-bar', +}) => { + const insets = useSafeAreaInsets(); + + return ( + + + + ); +}; diff --git a/app/src/components/proof-request/ConnectedWalletBadge.tsx b/app/src/components/proof-request/ConnectedWalletBadge.tsx new file mode 100644 index 000000000..905f196c1 --- /dev/null +++ b/app/src/components/proof-request/ConnectedWalletBadge.tsx @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { Pressable } from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { WalletIcon } from '@/components/proof-request/icons'; + +export interface ConnectedWalletBadgeProps { + address: string; + userIdType?: string; + onToggle?: () => void; + testID?: string; +} + +/** + * Blue badge showing connected wallet address. + * Matches Figma design 15234:9295 (icon). + */ +export const ConnectedWalletBadge: React.FC = ({ + address, + userIdType, + onToggle, + testID = 'connected-wallet-badge', +}) => { + const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID'; + + const content = ( + + {/* Label with icon */} + + + + {label} + + + + {/* Address */} + + + {truncateAddress(address)} + + + + ); + + if (onToggle) { + return ( + + {content} + + ); + } + + return content; +}; + +/** + * Truncates a wallet address for display. + * @example truncateAddress("0x1234567890abcdef1234567890abcdef12345678") // "0x12..5678" + */ +export function truncateAddress( + address: string, + startChars = 4, + endChars = 4, +): string { + if (address.length <= startChars + endChars + 2) { + return address; + } + return `${address.slice(0, startChars)}..${address.slice(-endChars)}`; +} diff --git a/app/src/components/proof-request/DisclosureItem.tsx b/app/src/components/proof-request/DisclosureItem.tsx new file mode 100644 index 000000000..22e506c06 --- /dev/null +++ b/app/src/components/proof-request/DisclosureItem.tsx @@ -0,0 +1,85 @@ +// 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 React from 'react'; +import { Pressable } from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { + FilledCircleIcon, + InfoCircleIcon, +} from '@/components/proof-request/icons'; + +export interface DisclosureItemProps { + text: string; + verified?: boolean; + onInfoPress?: () => void; + isLast?: boolean; + testID?: string; +} + +/** + * Individual disclosure row with green checkmark and optional info button. + * Matches Figma design 15234:9267. + */ +export const DisclosureItem: React.FC = ({ + text, + verified = true, + onInfoPress, + isLast = false, + testID = 'disclosure-item', +}) => { + return ( + + {/* Status Icon */} + + + + + {/* Disclosure Text */} + + + {text} + + + + {/* Info Button */} + {onInfoPress && ( + + + + + + )} + + ); +}; diff --git a/app/src/components/proof-request/ProofMetadataBar.tsx b/app/src/components/proof-request/ProofMetadataBar.tsx new file mode 100644 index 000000000..be7337a15 --- /dev/null +++ b/app/src/components/proof-request/ProofMetadataBar.tsx @@ -0,0 +1,89 @@ +// 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 React from 'react'; +import { Text, View, XStack } from 'tamagui'; + +import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { DocumentIcon } from '@/components/proof-request/icons'; + +export interface ProofMetadataBarProps { + timestamp: string; + testID?: string; +} + +/** + * Gray metadata bar showing "PROOFS REQUESTED" label and timestamp. + * Matches Figma design 15234:9281. + */ +export const ProofMetadataBar: React.FC = ({ + timestamp, + testID = 'proof-metadata-bar', +}) => { + return ( + + + {/* Icon + Label group */} + + + + Proofs Requested + + + + {/* Dot separator */} + + • + + + {/* Timestamp */} + + {timestamp} + + + + ); +}; + +/** + * Formats a Date object to match the Figma timestamp format. + * @example formatTimestamp(new Date()) // "4/7/2025 11:44 AM" + */ +export function formatTimestamp(date: Date): string { + const month = date.getMonth() + 1; + const day = date.getDate(); + const year = date.getFullYear(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + const displayMinutes = minutes.toString().padStart(2, '0'); + + return `${month}/${day}/${year} ${displayHours}:${displayMinutes} ${ampm}`; +} diff --git a/app/src/components/proof-request/ProofRequestCard.tsx b/app/src/components/proof-request/ProofRequestCard.tsx new file mode 100644 index 000000000..7d50c734e --- /dev/null +++ b/app/src/components/proof-request/ProofRequestCard.tsx @@ -0,0 +1,138 @@ +// 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 React, { useMemo } from 'react'; +import type { + ImageSourcePropType, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView as ScrollViewType, +} from 'react-native'; +import { ScrollView } from 'react-native'; +import { Text, View } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { + proofRequestColors, + proofRequestSpacing, +} from '@/components/proof-request/designTokens'; +import { + formatTimestamp, + ProofMetadataBar, +} from '@/components/proof-request/ProofMetadataBar'; +import { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader'; + +export interface ProofRequestCardProps { + logoSource: ImageSourcePropType | null; + appName: string; + appUrl: string | null; + documentType?: string; + timestamp?: Date; + children?: React.ReactNode; + testID?: string; + onScroll?: (event: NativeSyntheticEvent) => void; + scrollViewRef?: React.RefObject; + onContentSizeChange?: (width: number, height: number) => void; + onLayout?: (event: LayoutChangeEvent) => void; + initialScrollOffset?: number; +} + +/** + * Main card container for proof request screens. + * Combines header, metadata bar, and content section. + * Matches Figma design 15234:9267. + */ +export const ProofRequestCard: React.FC = ({ + logoSource, + appName, + appUrl, + documentType = '', + timestamp, + children, + testID = 'proof-request-card', + onScroll, + scrollViewRef, + onContentSizeChange, + onLayout, + initialScrollOffset, +}) => { + // Create default timestamp once and reuse it to avoid unnecessary re-renders + const defaultTimestamp = useMemo(() => new Date(), []); + const effectiveTimestamp = timestamp ?? defaultTimestamp; + + // Build request message with highlighted app name and document type + const requestMessage = ( + <> + + {appName} + + + { + ' is requesting access to the following information from your verified ' + } + + + {documentType} + + + . + + + ); + + return ( + + + {/* Black Header */} + + + {/* Metadata Bar */} + + + {/* White Content Area */} + + + {children} + + + + + ); +}; diff --git a/app/src/components/proof-request/ProofRequestHeader.tsx b/app/src/components/proof-request/ProofRequestHeader.tsx new file mode 100644 index 000000000..4751c03f0 --- /dev/null +++ b/app/src/components/proof-request/ProofRequestHeader.tsx @@ -0,0 +1,104 @@ +// 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 React from 'react'; +import type { ImageSourcePropType } from 'react-native'; +import { Image, Text, View, YStack } from 'tamagui'; + +import { + advercase, + dinot, + plexMono, +} from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; + +export interface ProofRequestHeaderProps { + logoSource: ImageSourcePropType | null; + appName: string; + appUrl: string | null; + requestMessage: React.ReactNode; + testID?: string; +} + +/** + * Black header section for proof request screens. + * Displays app logo, name, URL, and request description. + * Matches Figma design 15234:9267. + */ +export const ProofRequestHeader: React.FC = ({ + logoSource, + appName, + appUrl, + requestMessage, + testID = 'proof-request-header', +}) => { + const hasLogo = logoSource !== null; + + return ( + + {/* Logo and App Info Row */} + + {logoSource && ( + + + + )} + + + {appName} + + {appUrl && ( + + + {appUrl} + + + )} + + + + {/* Request Description */} + + {requestMessage} + + + ); +}; diff --git a/app/src/components/proof-request/WalletAddressModal.tsx b/app/src/components/proof-request/WalletAddressModal.tsx new file mode 100644 index 000000000..e73ecd511 --- /dev/null +++ b/app/src/components/proof-request/WalletAddressModal.tsx @@ -0,0 +1,240 @@ +// 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 React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Modal, Pressable, StyleSheet } from 'react-native'; +import { Text, View, XStack, YStack } from 'tamagui'; +import Clipboard from '@react-native-clipboard/clipboard'; + +import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { CopyIcon, WalletIcon } from '@/components/proof-request/icons'; + +export interface WalletAddressModalProps { + visible: boolean; + onClose: () => void; + address: string; + userIdType?: string; + testID?: string; +} + +/** + * Modal that displays the full wallet address with copy functionality. + * Appears when user taps on the truncated wallet badge. + */ +export const WalletAddressModal: React.FC = ({ + visible, + onClose, + address, + userIdType, + testID = 'wallet-address-modal', +}) => { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID'; + + // Reset copied state when modal closes + useEffect(() => { + if (!visible) { + setCopied(false); + } + }, [visible]); + + // Clear timeout on unmount or when modal closes/address changes + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [visible, address, onClose]); + + const handleCopy = useCallback(() => { + // Clear any existing timeout before setting a new one + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + Clipboard.setString(address); + setCopied(true); + + // Reset copied state and close after a brief delay + timeoutRef.current = setTimeout(() => { + setCopied(false); + onClose(); + timeoutRef.current = null; + }, 800); + }, [address, onClose]); + + return ( + + + + e.stopPropagation()}> + + {/* Header */} + + + + + {label} + + + + + {/* Full Address */} + + + {address} + + + + {/* Action Buttons */} + + [ + copied ? styles.copiedButton : styles.copyButton, + pressed && !copied && styles.copyButtonPressed, + ]} + testID={`${testID}-copy`} + > + + {copied ? ( + + ✓ + + ) : ( + + )} + + {copied ? 'Copied!' : 'Copy'} + + + + + {!copied && ( + [ + styles.closeButton, + pressed && styles.closeButtonPressed, + ]} + testID={`${testID}-close`} + > + + + Close + + + + )} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + copyButton: { + flex: 1, + backgroundColor: proofRequestColors.blue600, + borderRadius: 8, + }, + copyButtonPressed: { + backgroundColor: proofRequestColors.blue700, + }, + copiedButton: { + flex: 1, + backgroundColor: proofRequestColors.emerald500, + borderRadius: 8, + }, + closeButton: { + flex: 1, + backgroundColor: proofRequestColors.slate100, + borderRadius: 8, + borderWidth: 1, + borderColor: proofRequestColors.slate200, + }, + closeButtonPressed: { + backgroundColor: proofRequestColors.slate200, + }, +}); diff --git a/app/src/components/proof-request/designTokens.ts b/app/src/components/proof-request/designTokens.ts new file mode 100644 index 000000000..5a5aa8079 --- /dev/null +++ b/app/src/components/proof-request/designTokens.ts @@ -0,0 +1,40 @@ +// 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. + +/** + * Design tokens for proof request components. + * Extracted from Figma design 15234:9267 and 15234:9322. + */ + +export const proofRequestColors = { + // Base colors + black: '#000000', + white: '#FFFFFF', + + // Slate palette + slate100: '#F8FAFC', + slate200: '#E2E8F0', + slate400: '#94A3B8', + slate500: '#71717A', + slate900: '#0F172A', + + // Blue palette + blue500: '#3B82F6', + blue600: '#2563EB', + blue700: '#1D4ED8', + + // Status colors + emerald500: '#10B981', + + // Zinc palette + zinc500: '#71717A', +} as const; + +export const proofRequestSpacing = { + cardPadding: 20, + headerPadding: 30, + itemPadding: 16, + borderRadius: 10, + borderRadiusSmall: 4, +} as const; diff --git a/app/src/components/proof-request/icons.tsx b/app/src/components/proof-request/icons.tsx new file mode 100644 index 000000000..9f31470b7 --- /dev/null +++ b/app/src/components/proof-request/icons.tsx @@ -0,0 +1,143 @@ +// 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 React from 'react'; +import Svg, { Circle, Path, Rect } from 'react-native-svg'; + +export interface IconProps { + size?: number; + color?: string; +} + +/** + * Chevron up/down icon (dropdown) + */ +export const ChevronUpDownIcon: React.FC = ({ + size = 20, + color = '#94A3B8', +}) => ( + + + +); + +/** + * Copy icon + */ +export const CopyIcon: React.FC = ({ + size = 16, + color = '#FFFFFF', +}) => ( + + + + +); + +/** + * Document icon (lighter stroke to match SF Symbol design) + */ +export const DocumentIcon: React.FC = ({ + size = 18, + color = '#94A3B8', +}) => ( + + + + + +); + +/** + * Filled circle icon (checkmark/bullet point) + */ +export const FilledCircleIcon: React.FC = ({ + size = 18, + color = '#10B981', +}) => ( + + + +); + +/** + * Info circle icon + */ +export const InfoCircleIcon: React.FC = ({ + size = 20, + color = '#3B82F6', +}) => ( + + + + +); + +/** + * Wallet icon (credit card style to match SF Symbol creditcard 􀟿) + */ +export const WalletIcon: React.FC = ({ + size = 16, + color = '#FFFFFF', +}) => ( + + + + +); diff --git a/app/src/components/proof-request/index.ts b/app/src/components/proof-request/index.ts new file mode 100644 index 000000000..ac1503031 --- /dev/null +++ b/app/src/components/proof-request/index.ts @@ -0,0 +1,68 @@ +// 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. + +export type { BottomActionBarProps } from '@/components/proof-request/BottomActionBar'; +export type { BottomVerifyBarProps } from '@/components/proof-request/BottomVerifyBar'; + +// Metadata bar +export type { ConnectedWalletBadgeProps } from '@/components/proof-request/ConnectedWalletBadge'; + +export type { DisclosureItemProps } from '@/components/proof-request/DisclosureItem'; + +export type { IconProps } from '@/components/proof-request/icons'; + +// Header section +export type { ProofMetadataBarProps } from '@/components/proof-request/ProofMetadataBar'; + +/** + * Proof Request Component Library + * + * Shared components for proof request preview and proving screens. + * These components implement the Figma designs 15234:9267 and 15234:9322. + */ +// Main card component +export type { ProofRequestCardProps } from '@/components/proof-request/ProofRequestCard'; +export type { ProofRequestHeaderProps } from '@/components/proof-request/ProofRequestHeader'; + +export type { WalletAddressModalProps } from '@/components/proof-request/WalletAddressModal'; + +// Icons +export { BottomActionBar } from '@/components/proof-request/BottomActionBar'; +export { BottomVerifyBar } from '@/components/proof-request/BottomVerifyBar'; + +// Bottom action bar +export { + ChevronUpDownIcon, + CopyIcon, + DocumentIcon, + FilledCircleIcon, + InfoCircleIcon, + WalletIcon, +} from '@/components/proof-request/icons'; + +export { + ConnectedWalletBadge, + truncateAddress, +} from '@/components/proof-request/ConnectedWalletBadge'; + +// Connected wallet badge +export { DisclosureItem } from '@/components/proof-request/DisclosureItem'; + +// Disclosure item +export { + ProofMetadataBar, + formatTimestamp, +} from '@/components/proof-request/ProofMetadataBar'; + +export { ProofRequestCard } from '@/components/proof-request/ProofRequestCard'; + +export { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader'; + +export { WalletAddressModal } from '@/components/proof-request/WalletAddressModal'; + +// Design tokens +export { + proofRequestColors, + proofRequestSpacing, +} from '@/components/proof-request/designTokens'; diff --git a/app/src/hooks/useEarnPointsFlow.ts b/app/src/hooks/useEarnPointsFlow.ts index 87dc80ba6..53749da9a 100644 --- a/app/src/hooks/useEarnPointsFlow.ts +++ b/app/src/hooks/useEarnPointsFlow.ts @@ -40,7 +40,7 @@ export const useEarnPointsFlow = ({ // Use setTimeout to ensure modal dismisses before navigating setTimeout(() => { - navigation.navigate('Prove'); + navigation.navigate('ProvingScreenRouter'); }, 100); }, [selfClient, navigation]); diff --git a/app/src/hooks/useSelfAppData.ts b/app/src/hooks/useSelfAppData.ts new file mode 100644 index 000000000..913662759 --- /dev/null +++ b/app/src/hooks/useSelfAppData.ts @@ -0,0 +1,62 @@ +// 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 { useMemo } from 'react'; + +import type { SelfApp } from '@selfxyz/common'; +import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; +import { formatEndpoint } from '@selfxyz/common/utils/scope'; + +import { getDisclosureItems } from '@/utils/disclosureUtils'; +import { formatUserId } from '@/utils/formatUserId'; + +/** + * Hook that extracts and transforms SelfApp data for use in UI components. + * Returns memoized values for logo source, URL, formatted user ID, and disclosure items. + */ +export function useSelfAppData(selfApp: SelfApp | null) { + const logoSource = useMemo(() => { + if (!selfApp?.logoBase64) { + return null; + } + + // Check if the logo is already a URL + if ( + selfApp.logoBase64.startsWith('http://') || + selfApp.logoBase64.startsWith('https://') + ) { + return { uri: selfApp.logoBase64 }; + } + + // Otherwise handle as base64 + const base64String = selfApp.logoBase64.startsWith('data:image') + ? selfApp.logoBase64 + : `data:image/png;base64,${selfApp.logoBase64}`; + return { uri: base64String }; + }, [selfApp?.logoBase64]); + + const url = useMemo(() => { + if (!selfApp?.endpoint) { + return null; + } + return formatEndpoint(selfApp.endpoint); + }, [selfApp?.endpoint]); + + const formattedUserId = useMemo( + () => formatUserId(selfApp?.userId, selfApp?.userIdType), + [selfApp?.userId, selfApp?.userIdType], + ); + + const disclosureItems = useMemo(() => { + const disclosures = (selfApp?.disclosures as SelfAppDisclosureConfig) || {}; + return getDisclosureItems(disclosures); + }, [selfApp?.disclosures]); + + return { + logoSource, + url, + formattedUserId, + disclosureItems, + }; +} diff --git a/app/src/hooks/useSelfAppStalenessCheck.ts b/app/src/hooks/useSelfAppStalenessCheck.ts new file mode 100644 index 000000000..89614c9cb --- /dev/null +++ b/app/src/hooks/useSelfAppStalenessCheck.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useCallback } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { SelfApp } from '@selfxyz/common'; + +import type { RootStackParamList } from '@/navigation'; + +/** + * Hook that checks if SelfApp data is stale (missing or empty disclosures) + * and navigates to Home screen if stale data is detected. + * + * Uses a small delay to allow store updates to propagate after navigation + * (e.g., after QR code scan sets selfApp data). + */ +export function useSelfAppStalenessCheck( + selfApp: SelfApp | null, + disclosureItems: Array<{ key: string; text: string }>, + navigation: NativeStackNavigationProp, +) { + useFocusEffect( + useCallback(() => { + // Add a small delay to allow Zustand store updates to propagate + // after navigation (e.g., when selfApp is set from QR scan) + const timeoutId = setTimeout(() => { + if (!selfApp || disclosureItems.length === 0) { + navigation.navigate({ name: 'Home', params: {} }); + } + }, 300); + + return () => { + clearTimeout(timeoutId); + }; + }, [selfApp, disclosureItems.length, navigation]), + ); +} diff --git a/app/src/navigation/account.ts b/app/src/navigation/account.ts index 7b1656dd9..93f4f8f60 100644 --- a/app/src/navigation/account.ts +++ b/app/src/navigation/account.ts @@ -16,6 +16,7 @@ import AccountRecoveryScreen from '@/screens/account/recovery/AccountRecoveryScr import DocumentDataNotFoundScreen from '@/screens/account/recovery/DocumentDataNotFoundScreen'; import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhraseScreen'; import CloudBackupScreen from '@/screens/account/settings/CloudBackupScreen'; +import { ProofSettingsScreen } from '@/screens/account/settings/ProofSettingsScreen'; import SettingsScreen from '@/screens/account/settings/SettingsScreen'; import ShowRecoveryPhraseScreen from '@/screens/account/settings/ShowRecoveryPhraseScreen'; import { IS_EUCLID_ENABLED } from '@/utils/devUtils'; @@ -65,6 +66,18 @@ const accountScreens = { }, } as NativeStackNavigationOptions, }, + ProofSettings: { + screen: ProofSettingsScreen, + options: { + title: 'Proof Settings', + headerStyle: { + backgroundColor: white, + }, + headerTitleStyle: { + color: black, + }, + } as NativeStackNavigationOptions, + }, Settings: { screen: SettingsScreen, options: { diff --git a/app/src/navigation/deeplinks.ts b/app/src/navigation/deeplinks.ts index dc1cac680..af4cd79ce 100644 --- a/app/src/navigation/deeplinks.ts +++ b/app/src/navigation/deeplinks.ts @@ -126,7 +126,10 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().startAppListener(selfAppJson.sessionId); navigationRef.reset( - createDeeplinkNavigationState('Prove', correctParentScreen), + createDeeplinkNavigationState( + 'ProvingScreenRouter', + correctParentScreen, + ), ); return; @@ -143,7 +146,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().startAppListener(sessionId); navigationRef.reset( - createDeeplinkNavigationState('Prove', correctParentScreen), + createDeeplinkNavigationState('ProvingScreenRouter', correctParentScreen), ); } else if (mock_passport) { try { diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index 4ce48e8f3..67273f933 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -73,6 +73,8 @@ export type RootStackParamList = Omit< | 'Disclaimer' | 'DocumentNFCScan' | 'DocumentOnboarding' + | 'DocumentSelectorForProving' + | 'ProvingScreenRouter' | 'Gratification' | 'Home' | 'IDPicker' @@ -142,13 +144,24 @@ export type RootStackParamList = Omit< returnToScreen?: 'Points'; } | undefined; + ProofSettings: undefined; AccountVerifiedSuccess: undefined; // Proof/Verification screens ProofHistoryDetail: { data: ProofHistory; }; - Prove: undefined; + Prove: + | { + scrollOffset?: number; + } + | undefined; + ProvingScreenRouter: undefined; + DocumentSelectorForProving: + | { + documentType?: string; + } + | undefined; // App screens Loading: { diff --git a/app/src/navigation/verification.ts b/app/src/navigation/verification.ts index 24492bdfc..549986362 100644 --- a/app/src/navigation/verification.ts +++ b/app/src/navigation/verification.ts @@ -6,11 +6,30 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen'; import ProofRequestStatusScreen from '@/screens/verification/ProofRequestStatusScreen'; import ProveScreen from '@/screens/verification/ProveScreen'; +import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter'; import QRCodeTroubleScreen from '@/screens/verification/QRCodeTroubleScreen'; import QRCodeViewFinderScreen from '@/screens/verification/QRCodeViewFinderScreen'; +/** + * Shared header configuration for proof request screens + */ +const proofRequestHeaderOptions: NativeStackNavigationOptions = { + title: 'Proof Requested', + headerStyle: { + backgroundColor: black, + }, + headerTitleStyle: { + color: white, + fontWeight: '600', + }, + headerTintColor: white, + gestureEnabled: false, + animation: 'none', +}; + const verificationScreens = { ProofRequestStatus: { screen: ProofRequestStatusScreen, @@ -20,18 +39,17 @@ const verificationScreens = { gestureEnabled: false, } as NativeStackNavigationOptions, }, + ProvingScreenRouter: { + screen: ProvingScreenRouter, + options: proofRequestHeaderOptions, + }, + DocumentSelectorForProving: { + screen: DocumentSelectorForProvingScreen, + options: proofRequestHeaderOptions, + }, Prove: { screen: ProveScreen, - options: { - title: 'Request Proof', - headerStyle: { - backgroundColor: black, - }, - headerTitleStyle: { - color: white, - }, - gestureEnabled: false, - } as NativeStackNavigationOptions, + options: proofRequestHeaderOptions, }, QRCodeTrouble: { screen: QRCodeTroubleScreen, diff --git a/app/src/screens/account/settings/ProofSettingsScreen.tsx b/app/src/screens/account/settings/ProofSettingsScreen.tsx new file mode 100644 index 000000000..2227a5be9 --- /dev/null +++ b/app/src/screens/account/settings/ProofSettingsScreen.tsx @@ -0,0 +1,130 @@ +// 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 React from 'react'; +import { StyleSheet, Switch, Text, View } from 'react-native'; +import { ScrollView, YStack } from 'tamagui'; + +import { + black, + blue600, + slate200, + slate500, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { useSettingStore } from '@/stores/settingStore'; + +const ProofSettingsScreen: React.FC = () => { + const { + skipDocumentSelector, + setSkipDocumentSelector, + skipDocumentSelectorIfSingle, + setSkipDocumentSelectorIfSingle, + } = useSettingStore(); + + return ( + + + + Document Selection + + + + + Always skip document selection + + + Go directly to proof generation using your previously selected + or first available document + + + + + + + + + + + Skip when only one document + + + Automatically select your document when you only have one valid + ID available + + + + + + {skipDocumentSelector && ( + + Document selection is always skipped. The "Skip when only one + document" setting has no effect. + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + sectionTitle: { + fontSize: 14, + fontFamily: dinot, + fontWeight: '600', + color: slate500, + textTransform: 'uppercase', + letterSpacing: 1, + }, + settingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + }, + settingTextContainer: { + flex: 1, + gap: 4, + }, + settingLabel: { + fontSize: 16, + fontFamily: dinot, + fontWeight: '500', + color: black, + }, + settingDescription: { + fontSize: 14, + fontFamily: dinot, + color: slate500, + }, + divider: { + height: 1, + backgroundColor: slate200, + }, + infoText: { + fontSize: 13, + fontFamily: dinot, + fontStyle: 'italic', + color: slate500, + paddingHorizontal: 4, + }, +}); + +export { ProofSettingsScreen }; diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx index b8e76452d..535d52b56 100644 --- a/app/src/screens/account/settings/SettingsScreen.tsx +++ b/app/src/screens/account/settings/SettingsScreen.tsx @@ -12,7 +12,7 @@ import type { SvgProps } from 'react-native-svg'; import { Button, ScrollView, View, XStack, YStack } from 'tamagui'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Bug, FileText } from '@tamagui/lucide-icons'; +import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons'; import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components'; import { @@ -78,6 +78,7 @@ const routes = [Data, 'View document info', 'DocumentDataInfo'], [Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'], [Cloud, 'Cloud backup', 'CloudBackupSettings'], + [Settings2 as React.FC, 'Proof settings', 'ProofSettings'], [Feedback, 'Send feedback', 'email_feedback'], [ShareIcon, 'Share Self app', 'share'], [ @@ -88,6 +89,7 @@ const routes = ] satisfies [React.FC, string, RouteOption][]) : ([ [Data, 'View document info', 'DocumentDataInfo'], + [Settings2 as React.FC, 'Proof settings', 'ProofSettings'], [Feedback, 'Send feeback', 'email_feedback'], [ FileText as React.FC, diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx new file mode 100644 index 000000000..12ce5a348 --- /dev/null +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -0,0 +1,495 @@ +// 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 React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { ActivityIndicator, StyleSheet } from 'react-native'; +import { Text, View, YStack } from 'tamagui'; +import type { RouteProp } from '@react-navigation/native'; +import { + useFocusEffect, + useNavigation, + useRoute, +} from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + getDocumentAttributes, + isDocumentValidForProving, + useSelfClient, +} from '@selfxyz/mobile-sdk-alpha'; +import { blue600, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import type { IDSelectorState } from '@/components/documents'; +import { IDSelectorSheet, isDisabledState } from '@/components/documents'; +import { + BottomActionBar, + ConnectedWalletBadge, + DisclosureItem, + ProofRequestCard, + proofRequestColors, + truncateAddress, + WalletAddressModal, +} from '@/components/proof-request'; +import { useSelfAppData } from '@/hooks/useSelfAppData'; +import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck'; +import type { RootStackParamList } from '@/navigation'; +import { usePassport } from '@/providers/passportDataProvider'; +import { getDocumentTypeName } from '@/utils/documentUtils'; + +function getDocumentDisplayName( + metadata: DocumentMetadata, + documentData?: IDDocument, +): string { + const category = metadata.documentCategory || ''; + const isMock = metadata.mock; + + // Extract country information from document data + let countryCode: string | null = null; + if (documentData) { + try { + const attributes = getDocumentAttributes(documentData); + countryCode = attributes.nationalitySlice || null; + } catch { + // If we can't extract attributes, continue without country + } + } + + const mockPrefix = isMock ? 'Dev ' : ''; + + if (category === 'passport') { + const base = 'Passport'; + return countryCode + ? `${mockPrefix}${countryCode} ${base}` + : `${mockPrefix}${base}`; + } else if (category === 'id_card') { + const base = 'ID Card'; + return countryCode + ? `${mockPrefix}${countryCode} ${base}` + : `${mockPrefix}${base}`; + } else if (category === 'aadhaar') { + return isMock ? 'Dev Aadhaar ID' : 'Aadhaar ID'; + } + + return isMock ? `Dev ${metadata.documentType}` : metadata.documentType; +} + +function determineDocumentState( + metadata: DocumentMetadata, + documentData: IDDocument | undefined, +): IDSelectorState { + // Use SDK to check if document is valid (not expired) + if (!isDocumentValidForProving(metadata, documentData)) { + return 'expired'; + } + + // UI-specific state mapping: Mock documents are selectable but marked as developer/mock + if (metadata.mock) { + return 'mock'; + } + + // Both registered and non-registered real documents are valid for selection + // They will be registered during the proving flow if needed + return 'verified'; +} + +const DocumentSelectorForProvingScreen: React.FC = () => { + const navigation = + useNavigation>(); + const route = + useRoute>(); + const selfClient = useSelfClient(); + const { useSelfAppStore } = selfClient; + const selfApp = useSelfAppStore(state => state.selfApp); + const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = + usePassport(); + // Extract SelfApp data using hook + const { logoSource, url, formattedUserId, disclosureItems } = + useSelfAppData(selfApp); + + // Check for stale data and navigate to Home if needed + useSelfAppStalenessCheck(selfApp, disclosureItems, navigation); + + const [documentCatalog, setDocumentCatalog] = useState({ + documents: [], + }); + const [allDocuments, setAllDocuments] = useState< + Record + >({}); + const [selectedDocumentId, setSelectedDocumentId] = useState< + string | undefined + >(); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [walletModalOpen, setWalletModalOpen] = useState(false); + const abortControllerRef = useRef(null); + const scrollOffsetRef = useRef(0); + + const pickInitialDocument = useCallback( + ( + catalog: DocumentCatalog, + docs: Record, + ) => { + if (catalog.selectedDocumentId) { + const selectedMeta = catalog.documents.find( + doc => doc.id === catalog.selectedDocumentId, + ); + const selectedData = selectedMeta + ? docs[catalog.selectedDocumentId] + : undefined; + + if (selectedMeta && selectedData) { + const state = determineDocumentState(selectedMeta, selectedData.data); + if (!isDisabledState(state)) { + return catalog.selectedDocumentId; + } + } else if (selectedMeta) { + return catalog.selectedDocumentId; + } + } + + const firstValid = catalog.documents.find(doc => { + const docData = docs[doc.id]; + const state = determineDocumentState(doc, docData?.data); + return !isDisabledState(state); + }); + + return firstValid?.id; + }, + [], + ); + + const loadDocuments = useCallback(async () => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(null); + try { + const catalog = await loadDocumentCatalog(); + const docs = await getAllDocuments(); + + // Don't update state if this request was aborted + if (controller.signal.aborted) { + return; + } + + setDocumentCatalog(catalog); + setAllDocuments(docs); + setSelectedDocumentId(pickInitialDocument(catalog, docs)); + } catch (loadError) { + // Don't show error if this request was aborted + if (controller.signal.aborted) { + return; + } + console.warn('Failed to load documents:', loadError); + setError('Unable to load documents.'); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, [getAllDocuments, loadDocumentCatalog, pickInitialDocument]); + + useFocusEffect( + useCallback(() => { + loadDocuments(); + }, [loadDocuments]), + ); + + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const documents = useMemo(() => { + return documentCatalog.documents + .map(metadata => { + const docData = allDocuments[metadata.id]; + const baseState = determineDocumentState(metadata, docData?.data); + const isSelected = metadata.id === selectedDocumentId; + const itemState = + isSelected && !isDisabledState(baseState) ? 'active' : baseState; + + return { + id: metadata.id, + name: getDocumentDisplayName(metadata, docData?.data), + state: itemState, + }; + }) + .sort((a, b) => { + // Get metadata for both documents + const metaA = documentCatalog.documents.find(d => d.id === a.id); + const metaB = documentCatalog.documents.find(d => d.id === b.id); + + // Sort real documents before mock documents + if (metaA && metaB) { + if (metaA.mock !== metaB.mock) { + return metaA.mock ? 1 : -1; // Real first + } + } + + // Within same type (real/mock), sort alphabetically by name + return a.name.localeCompare(b.name); + }); + }, [allDocuments, documentCatalog.documents, selectedDocumentId]); + + const selectedDocument = documents.find(doc => doc.id === selectedDocumentId); + const canContinue = + !!selectedDocument && !isDisabledState(selectedDocument.state); + + // Get document type for the proof request message + const selectedDocumentType = useMemo(() => { + // If we have a preloaded document type from route params, use it while loading + const preloadedType = route.params?.documentType; + if (loading && preloadedType) { + return preloadedType; + } + + if (!selectedDocumentId) return preloadedType || ''; + const metadata = documentCatalog.documents.find( + d => d.id === selectedDocumentId, + ); + return getDocumentTypeName(metadata?.documentCategory); + }, [ + selectedDocumentId, + documentCatalog.documents, + loading, + route.params?.documentType, + ]); + + const handleSelect = useCallback((documentId: string) => { + setSelectedDocumentId(documentId); + }, []); + + const handleSheetSelect = useCallback(async () => { + if (!selectedDocumentId || !canContinue || submitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await setSelectedDocument(selectedDocumentId); + setSheetOpen(false); // Close the sheet first + navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current }); + } catch (selectionError) { + console.error('Failed to set selected document:', selectionError); + setError('Failed to select document. Please try again.'); + } finally { + setSubmitting(false); + } + }, [ + selectedDocumentId, + canContinue, + submitting, + setSelectedDocument, + navigation, + ]); + + const handleApprove = async () => { + if (!selectedDocumentId || !canContinue || submitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await setSelectedDocument(selectedDocumentId); + navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current }); + } catch (selectionError) { + console.error('Failed to set selected document:', selectionError); + setError('Failed to select document. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, + [], + ); + + // Loading state + if (loading) { + return ( + + + + ); + } + + // Error state + if (error) { + return ( + + + {error} + + + + Retry + + + + ); + } + + // Empty state + if (documents.length === 0) { + return ( + + + No documents found. Please scan a document first. + + + ); + } + + return ( + + {/* Main Content - Proof Request Card */} + + {/* Connected Wallet Badge */} + {formattedUserId && ( + setWalletModalOpen(true)} + testID="document-selector-wallet-badge" + /> + )} + + {/* Disclosure Items */} + + {disclosureItems.map((item, index) => ( + + ))} + + + + {/* Bottom Action Bar */} + setSheetOpen(true)} + onApprovePress={handleApprove} + approveDisabled={!canContinue} + approving={submitting} + testID="document-selector-action-bar" + /> + + {/* ID Selector Sheet */} + setSheetOpen(false)} + onApprove={handleSheetSelect} + testID="document-selector-sheet" + /> + + {/* Wallet Address Modal */} + {formattedUserId && selfApp?.userId && ( + setWalletModalOpen(false)} + address={selfApp.userId} + userIdType={selfApp?.userIdType} + testID="document-selector-wallet-modal" + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: white, + }, +}); + +export { DocumentSelectorForProvingScreen }; diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 57a0f3dbe..af48dd7c3 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import LottieView from 'lottie-react-native'; import React, { useCallback, useEffect, @@ -14,33 +13,34 @@ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, + ScrollView as ScrollViewType, } from 'react-native'; -import { ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; -import { Image, Text, View, XStack, YStack } from 'tamagui'; -import { useIsFocused, useNavigation } from '@react-navigation/native'; +import { StyleSheet } from 'react-native'; +import { View, YStack } from 'tamagui'; +import type { RouteProp } from '@react-navigation/native'; +import { + useIsFocused, + useNavigation, + useRoute, +} 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 { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; -import miscAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json'; -import { - BodyText, - Caption, - HeldPrimaryButtonProveScreen, -} from '@selfxyz/mobile-sdk-alpha/components'; import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import { - black, - slate300, - white, -} from '@selfxyz/mobile-sdk-alpha/constants/colors'; -import Disclosures from '@/components/Disclosures'; +import { + BottomVerifyBar, + ConnectedWalletBadge, + DisclosureItem, + ProofRequestCard, + proofRequestColors, + truncateAddress, + WalletAddressModal, +} from '@/components/proof-request'; +import { useSelfAppData } from '@/hooks/useSelfAppData'; +import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck'; import { buttonTap } from '@/integrations/haptics'; -import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { RootStackParamList } from '@/navigation'; import { setDefaultDocumentTypeIfNeeded, @@ -56,26 +56,41 @@ import { checkDocumentExpiration, getDocumentAttributes, } from '@/utils/documentAttributes'; -import { formatUserId } from '@/utils/formatUserId'; +import { getDocumentTypeName } from '@/utils/documentUtils'; const ProveScreen: React.FC = () => { const selfClient = useSelfClient(); const { trackEvent } = selfClient; - const { navigate } = + const navigation = useNavigation>(); + const { navigate } = navigation; + const route = useRoute>(); const isFocused = useIsFocused(); const { useProvingStore, useSelfAppStore } = selfClient; const selectedApp = useSelfAppStore(state => state.selfApp); + + // Extract SelfApp data using hook + const { logoSource, url, formattedUserId, disclosureItems } = + useSelfAppData(selectedApp); + + // Check for stale data and navigate to Home if needed + useSelfAppStalenessCheck( + selectedApp, + disclosureItems, + navigation as NativeStackNavigationProp, + ); const selectedAppRef = useRef(null); const processedSessionsRef = useRef>(new Set()); const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0); const [scrollViewHeight, setScrollViewHeight] = useState(0); - const [showFullAddress, setShowFullAddress] = useState(false); + const [hasLayoutMeasurements, setHasLayoutMeasurements] = useState(false); const [isDocumentExpired, setIsDocumentExpired] = useState(false); + const [documentType, setDocumentType] = useState(''); + const [walletModalOpen, setWalletModalOpen] = useState(false); const isDocumentExpiredRef = useRef(false); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef(null); const isContentShorterThanScrollView = useMemo( () => scrollViewContentHeight <= scrollViewHeight, @@ -92,6 +107,7 @@ const ProveScreen: React.FC = () => { const addHistory = async () => { if (provingStore.uuid && selectedApp) { const catalog = await loadDocumentCatalog(); + const selectedDocumentId = catalog.selectedDocumentId; addProofHistory({ @@ -109,15 +125,18 @@ const ProveScreen: React.FC = () => { } }; addHistory(); - }, [addProofHistory, provingStore.uuid, selectedApp, loadDocumentCatalog]); + }, [addProofHistory, loadDocumentCatalog, provingStore.uuid, selectedApp]); useEffect(() => { - if (isContentShorterThanScrollView) { - setHasScrolledToBottom(true); - } else { - setHasScrolledToBottom(false); + // Only update hasScrolledToBottom once we have real layout measurements + if (hasLayoutMeasurements) { + if (isContentShorterThanScrollView) { + setHasScrolledToBottom(true); + } else { + setHasScrolledToBottom(false); + } } - }, [isContentShorterThanScrollView]); + }, [isContentShorterThanScrollView, hasLayoutMeasurements]); useEffect(() => { if (!isFocused || !selectedApp) { @@ -142,6 +161,9 @@ const ProveScreen: React.FC = () => { setIsDocumentExpired(isExpired); isDocumentExpiredRef.current = isExpired; } + setDocumentType( + getDocumentTypeName(selectedDocument?.data?.documentCategory), + ); } catch (error) { console.error('Error checking document expiration:', error); setIsDocumentExpired(false); @@ -212,43 +234,6 @@ const ProveScreen: React.FC = () => { enhanceApp(); }, [selectedApp, selfClient]); - const disclosureOptions = useMemo(() => { - return (selectedApp?.disclosures as SelfAppDisclosureConfig) || []; - }, [selectedApp?.disclosures]); - - // Format the logo source based on whether it's a URL or base64 string - const logoSource = useMemo(() => { - if (!selectedApp?.logoBase64) { - return null; - } - - // Check if the logo is already a URL - if ( - selectedApp.logoBase64.startsWith('http://') || - selectedApp.logoBase64.startsWith('https://') - ) { - return { uri: selectedApp.logoBase64 }; - } - - // Otherwise handle as base64 as before - const base64String = selectedApp.logoBase64.startsWith('data:image') - ? selectedApp.logoBase64 - : `data:image/png;base64,${selectedApp.logoBase64}`; - return { uri: base64String }; - }, [selectedApp?.logoBase64]); - - const url = useMemo(() => { - if (!selectedApp?.endpoint) { - return null; - } - return formatEndpoint(selectedApp.endpoint); - }, [selectedApp?.endpoint]); - - const formattedUserId = useMemo( - () => formatUserId(selectedApp?.userId, selectedApp?.userIdType), - [selectedApp?.userId, selectedApp?.userIdType], - ); - function onVerify() { provingStore.setUserConfirmed(selfClient); buttonTap(); @@ -299,218 +284,99 @@ const ProveScreen: React.FC = () => { const handleContentSizeChange = useCallback( (contentWidth: number, contentHeight: number) => { setScrollViewContentHeight(contentHeight); + // If we now have both measurements and content fits on screen, enable button immediately + if (contentHeight > 0 && scrollViewHeight > 0) { + setHasLayoutMeasurements(true); + if (contentHeight <= scrollViewHeight) { + setHasScrolledToBottom(true); + } + } }, - [], + [scrollViewHeight], ); - const handleScrollViewLayout = useCallback((event: LayoutChangeEvent) => { - setScrollViewHeight(event.nativeEvent.layout.height); - }, []); - - const handleAddressToggle = useCallback(() => { - if (selectedApp?.userIdType === 'hex') { - setShowFullAddress(!showFullAddress); - buttonTap(); - } - }, [selectedApp?.userIdType, showFullAddress]); + const handleScrollViewLayout = useCallback( + (event: LayoutChangeEvent) => { + const layoutHeight = event.nativeEvent.layout.height; + setScrollViewHeight(layoutHeight); + // If we now have both measurements and content fits on screen, enable button immediately + if (layoutHeight > 0 && scrollViewContentHeight > 0) { + setHasLayoutMeasurements(true); + if (scrollViewContentHeight <= layoutHeight) { + setHasScrolledToBottom(true); + } + } + }, + [scrollViewContentHeight], + ); return ( - - - - {!selectedApp?.sessionId ? ( - - ) : ( - - {logoSource && ( - - )} - - {url} - - - {selectedApp.appName} is requesting - you to prove the following information: - - - )} - - - + - - + {formattedUserId && ( + setWalletModalOpen(true)} + testID="prove-screen-wallet-badge" + /> + )} - {/* Display connected wallet or UUID */} - {formattedUserId && ( - - - {selectedApp?.userIdType === 'hex' - ? 'Connected Wallet' - : 'Connected ID'} - : - - - - - - - {selectedApp?.userIdType === 'hex' && showFullAddress - ? selectedApp.userId - : formattedUserId} - - - {selectedApp?.userIdType === 'hex' && ( - - {showFullAddress ? ( - - ) : ( - - )} - - )} - - {selectedApp?.userIdType === 'hex' && ( - - {showFullAddress - ? 'Tap to hide address' - : 'Tap to show full address'} - - )} - - - - )} + + {disclosureItems.map((item, index) => ( + + ))} + + - {/* Display userDefinedData if it exists */} - {selectedApp?.userDefinedData && ( - - - Additional Information: - - - - {selectedApp.userDefinedData} - - - - )} + - - - Self will confirm that these details are accurate and none of your - confidential info will be revealed to {selectedApp?.appName} - - - - setWalletModalOpen(false)} + address={selectedApp.userId} + userIdType={selectedApp?.userIdType} + testID="prove-screen-wallet-modal" /> - - + )} + ); }; export default ProveScreen; const styles = StyleSheet.create({ - animation: { - top: 0, - width: 200, - height: 200, - transform: [{ scale: 2 }, { translateY: -20 }], + container: { + flex: 1, + backgroundColor: proofRequestColors.white, }, }); diff --git a/app/src/screens/verification/ProvingScreenRouter.tsx b/app/src/screens/verification/ProvingScreenRouter.tsx new file mode 100644 index 000000000..dc589853a --- /dev/null +++ b/app/src/screens/verification/ProvingScreenRouter.tsx @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator } from 'react-native'; +import { Text, View } from 'tamagui'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '@selfxyz/mobile-sdk-alpha'; +import { blue600 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request'; +import type { RootStackParamList } from '@/navigation'; +import { usePassport } from '@/providers/passportDataProvider'; +import { useSettingStore } from '@/stores/settingStore'; +import { getDocumentTypeName } from '@/utils/documentUtils'; + +/** + * Router screen for the proving flow that decides whether to skip the document selector. + * + * This screen: + * 1. Loads document catalog and counts valid documents + * 2. Checks skip settings (skipDocumentSelector, skipDocumentSelectorIfSingle) + * 3. Routes to appropriate screen: + * - No valid documents -> DocumentDataNotFound + * - Skip enabled -> auto-select and go to Prove + * - Otherwise -> DocumentSelectorForProving + */ +const ProvingScreenRouter: React.FC = () => { + const navigation = + useNavigation>(); + const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = + usePassport(); + const { skipDocumentSelector, skipDocumentSelectorIfSingle } = + useSettingStore(); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + const hasRoutedRef = useRef(false); + + const loadAndRoute = useCallback(async () => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + // Prevent double routing + if (hasRoutedRef.current) { + return; + } + + setError(null); + try { + const catalog = await loadDocumentCatalog(); + const docs = await getAllDocuments(); + + // Don't continue if this request was aborted + if (controller.signal.aborted) { + return; + } + + // Count valid documents + const validDocuments = catalog.documents.filter(doc => { + const docData = docs[doc.id]; + return isDocumentValidForProving(doc, docData?.data); + }); + + const validCount = validDocuments.length; + + // Mark as routed to prevent re-routing + hasRoutedRef.current = true; + + // Route based on document availability and skip settings + if (validCount === 0) { + // No valid documents - redirect to onboarding + navigation.replace('DocumentDataNotFound'); + return; + } + + // Determine document type from first valid document for display + const firstValidDoc = validDocuments[0]; + const documentType = getDocumentTypeName(firstValidDoc?.documentCategory); + + // Determine if we should skip the selector + const shouldSkip = + skipDocumentSelector || + (skipDocumentSelectorIfSingle && validCount === 1); + + if (shouldSkip) { + // Auto-select and navigate to Prove + const docToSelect = pickBestDocumentToSelect(catalog, docs); + if (docToSelect) { + try { + await setSelectedDocument(docToSelect); + navigation.replace('Prove'); + } catch (selectError) { + console.error('Failed to auto-select document:', selectError); + // On error, fall back to showing the selector + hasRoutedRef.current = false; + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } else { + // No valid document to select, show selector + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } else { + // Show the document selector + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } catch (loadError) { + // Don't show error if this request was aborted + if (controller.signal.aborted) { + return; + } + console.warn('Failed to load documents for routing:', loadError); + setError('Unable to load documents.'); + // Reset routed flag to allow retry + hasRoutedRef.current = false; + } + }, [ + getAllDocuments, + loadDocumentCatalog, + navigation, + setSelectedDocument, + skipDocumentSelector, + skipDocumentSelectorIfSingle, + ]); + + useFocusEffect( + useCallback(() => { + // Reset routing flag when screen gains focus + hasRoutedRef.current = false; + loadAndRoute(); + }, [loadAndRoute]), + ); + + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + return ( + + {error ? ( + + + {error} + + { + hasRoutedRef.current = false; + loadAndRoute(); + }} + pressStyle={{ opacity: 0.7 }} + testID="proving-router-retry" + > + + Retry + + + + ) : ( + <> + + + )} + + ); +}; + +export { ProvingScreenRouter }; diff --git a/app/src/screens/verification/QRCodeViewFinderScreen.tsx b/app/src/screens/verification/QRCodeViewFinderScreen.tsx index eea105562..f01f1942f 100644 --- a/app/src/screens/verification/QRCodeViewFinderScreen.tsx +++ b/app/src/screens/verification/QRCodeViewFinderScreen.tsx @@ -48,7 +48,7 @@ const QRCodeViewFinderScreen: React.FC = () => { const isFocused = useIsFocused(); const [doneScanningQR, setDoneScanningQR] = useState(false); const { top: safeAreaTop } = useSafeAreaInsets(); - const navigateToProve = useHapticNavigation('Prove'); + const navigateToDocumentSelector = useHapticNavigation('ProvingScreenRouter'); // This resets to the default state when we navigate back to this screen useFocusEffect( @@ -91,7 +91,7 @@ const QRCodeViewFinderScreen: React.FC = () => { .startAppListener(selfAppJson.sessionId); setTimeout(() => { - navigateToProve(); + navigateToDocumentSelector(); }, 100); } catch (parseError) { trackEvent(ProofEvents.QR_SCAN_FAILED, { @@ -115,7 +115,7 @@ const QRCodeViewFinderScreen: React.FC = () => { selfClient.getSelfAppState().startAppListener(sessionId); setTimeout(() => { - navigateToProve(); + navigateToDocumentSelector(); }, 100); } else { trackEvent(ProofEvents.QR_SCAN_FAILED, { @@ -129,7 +129,13 @@ const QRCodeViewFinderScreen: React.FC = () => { } } }, - [doneScanningQR, navigation, navigateToProve, trackEvent, selfClient], + [ + doneScanningQR, + navigation, + navigateToDocumentSelector, + trackEvent, + selfClient, + ], ); const shouldRenderCamera = !connectionModalVisible && !doneScanningQR; diff --git a/app/src/stores/settingStore.ts b/app/src/stores/settingStore.ts index 550bb792d..499234400 100644 --- a/app/src/stores/settingStore.ts +++ b/app/src/stores/settingStore.ts @@ -34,8 +34,12 @@ interface PersistedSettingsState { setKeychainMigrationCompleted: () => void; setLoggingSeverity: (severity: LoggingSeverity) => void; setPointsAddress: (address: string | null) => void; + setSkipDocumentSelector: (value: boolean) => void; + setSkipDocumentSelectorIfSingle: (value: boolean) => void; setSubscribedTopics: (topics: string[]) => void; setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void; + skipDocumentSelector: boolean; + skipDocumentSelectorIfSingle: boolean; subscribedTopics: string[]; toggleCloudBackupEnabled: () => void; turnkeyBackupEnabled: boolean; @@ -135,6 +139,14 @@ export const useSettingStore = create()( setPointsAddress: (address: string | null) => set({ pointsAddress: address }), + // Document selector skip settings + skipDocumentSelector: false, + setSkipDocumentSelector: (value: boolean) => + set({ skipDocumentSelector: value }), + skipDocumentSelectorIfSingle: true, + setSkipDocumentSelectorIfSingle: (value: boolean) => + set({ skipDocumentSelectorIfSingle: value }), + // Non-persisted state (will not be saved to storage) hideNetworkModal: false, setHideNetworkModal: (hideNetworkModal: boolean) => { diff --git a/app/src/utils/devUtils.ts b/app/src/utils/devUtils.ts index 1bd470b67..b26d77048 100644 --- a/app/src/utils/devUtils.ts +++ b/app/src/utils/devUtils.ts @@ -8,4 +8,4 @@ * Use this constant instead of checking __DEV__ directly throughout the codebase. */ export const IS_DEV_MODE = typeof __DEV__ !== 'undefined' && __DEV__; -export const IS_EUCLID_ENABLED = false; //IS_DEV_MODE; // just in case we forgot to turn it off before pushing to prod. +export const IS_EUCLID_ENABLED = false; // Enabled for proof request UI redesign diff --git a/app/src/utils/disclosureUtils.ts b/app/src/utils/disclosureUtils.ts new file mode 100644 index 000000000..5bcde4df0 --- /dev/null +++ b/app/src/utils/disclosureUtils.ts @@ -0,0 +1,89 @@ +// 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 { Country3LetterCode } from '@selfxyz/common/constants'; +import { countryCodes } from '@selfxyz/common/constants'; +import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; + +function listToString(list: string[]): string { + if (list.length === 1) { + return list[0]; + } else if (list.length === 2) { + return list.join(' nor '); + } + return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`; +} + +function countriesToSentence(countries: Country3LetterCode[]): string { + return listToString(countries.map(country => countryCodes[country])); +} + +export const ORDERED_DISCLOSURE_KEYS: Array = [ + 'issuing_state', + 'name', + 'passport_number', + 'nationality', + 'date_of_birth', + 'gender', + 'expiry_date', + 'ofac', + 'excludedCountries', + 'minimumAge', +] as const; + +export function getDisclosureItems( + disclosures: SelfAppDisclosureConfig, +): Array<{ key: string; text: string }> { + const items: Array<{ key: string; text: string }> = []; + + for (const key of ORDERED_DISCLOSURE_KEYS) { + const isEnabled = disclosures[key]; + if (!isEnabled || (Array.isArray(isEnabled) && isEnabled.length === 0)) { + continue; + } + + const text = getDisclosureText(key, disclosures); + if (text) { + items.push({ key, text }); + } + } + + return items; +} + +/** + * Generates the display text for a disclosure key. + * This is the single source of truth for disclosure text across the app. + */ +export function getDisclosureText( + key: keyof SelfAppDisclosureConfig, + disclosures: SelfAppDisclosureConfig, +): string { + switch (key) { + case 'ofac': + return 'I am not on the OFAC sanction list'; + case 'excludedCountries': + return `I am not a citizen of the following countries: ${countriesToSentence( + (disclosures.excludedCountries as Country3LetterCode[]) || [], + )}`; + case 'minimumAge': + return `Age is over ${disclosures.minimumAge}`; + case 'name': + return 'Name'; + case 'passport_number': + return 'Passport Number'; + case 'date_of_birth': + return 'Date of Birth'; + case 'gender': + return 'Gender'; + case 'expiry_date': + return 'Passport Expiry Date'; + case 'issuing_state': + return 'Issuing State'; + case 'nationality': + return 'Nationality'; + default: + return ''; + } +} diff --git a/app/src/utils/documentUtils.ts b/app/src/utils/documentUtils.ts new file mode 100644 index 000000000..d60e27133 --- /dev/null +++ b/app/src/utils/documentUtils.ts @@ -0,0 +1,19 @@ +// 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. + +/** + * Gets the document type display name for the proof request message. + */ +export function getDocumentTypeName(category: string | undefined): string { + switch (category) { + case 'passport': + return 'Passport'; + case 'id_card': + return 'ID Card'; + case 'aadhaar': + return 'Aadhaar'; + default: + return 'Document'; + } +} diff --git a/app/src/utils/index.ts b/app/src/utils/index.ts index f54dee0ae..62a866586 100644 --- a/app/src/utils/index.ts +++ b/app/src/utils/index.ts @@ -39,6 +39,9 @@ export { extraYPadding, normalizeBorderWidth } from '@/utils/styleUtils'; // JSON utilities export { formatUserId } from '@/utils/formatUserId'; +// Document utilities +export { getDocumentTypeName } from '@/utils/documentUtils'; + export { getModalCallbacks, registerModalCallbacks, diff --git a/app/tests/__setup__/mocks/navigation.js b/app/tests/__setup__/mocks/navigation.js index 4dfcf7d38..83159a837 100644 --- a/app/tests/__setup__/mocks/navigation.js +++ b/app/tests/__setup__/mocks/navigation.js @@ -9,10 +9,23 @@ jest.mock('@react-navigation/native', () => { const MockNavigator = (props, _ref) => props.children; MockNavigator.displayName = 'MockNavigator'; + // `useFocusEffect` should behave like an effect: it should not synchronously run + // on every re-render, otherwise any state updates inside the callback can cause + // an infinite render loop in tests. + const focusEffectCallbacks = new WeakSet(); + return { useFocusEffect: jest.fn(callback => { - // Immediately invoke the effect for testing without requiring a container - return callback(); + // Invoke only once per callback instance (per component mount), similar to + // how a real focus effect would run on focus rather than every render. + if ( + typeof callback === 'function' && + !focusEffectCallbacks.has(callback) + ) { + focusEffectCallbacks.add(callback); + return callback(); + } + return undefined; }), useNavigation: jest.fn(() => ({ navigate: jest.fn(), diff --git a/app/tests/__setup__/mocks/ui.js b/app/tests/__setup__/mocks/ui.js index 18d3b0d19..68c8b0e95 100644 --- a/app/tests/__setup__/mocks/ui.js +++ b/app/tests/__setup__/mocks/ui.js @@ -54,10 +54,18 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => { const Text = jest.fn(({ children, ...props }) => children || null); Text.displayName = 'MockText'; + const Title = jest.fn(({ children, ...props }) => children || null); + Title.displayName = 'MockTitle'; + + const View = jest.fn(({ children, ...props }) => children || null); + View.displayName = 'MockView'; + return { __esModule: true, Button, XStack, + Title, + View, // Provide minimal Text to satisfy potential usages Text, }; @@ -175,6 +183,10 @@ jest.mock('@tamagui/lucide-icons', () => { ExternalLink: makeIcon('external-link'), X: makeIcon('x'), Clipboard: makeIcon('clipboard'), + Check: makeIcon('check'), + Circle: makeIcon('circle'), + ChevronDown: makeIcon('chevron-down'), + ChevronLeft: makeIcon('chevron-left'), }; }); diff --git a/app/tests/src/components/documents/IDSelectorSheet.test.tsx b/app/tests/src/components/documents/IDSelectorSheet.test.tsx new file mode 100644 index 000000000..255d6f35e --- /dev/null +++ b/app/tests/src/components/documents/IDSelectorSheet.test.tsx @@ -0,0 +1,225 @@ +// 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 { fireEvent, render } from '@testing-library/react-native'; + +import type { IDSelectorDocument } from '@/components/documents'; +import { IDSelectorItem, IDSelectorSheet } from '@/components/documents'; + +describe('IDSelectorItem', () => { + const mockOnPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('test-item')).toBeTruthy(); + }); + + it('calls onPress when pressed on active state', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('test-item')); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('calls onPress when pressed on verified state', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('test-item')); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('renders different states correctly', () => { + // Render active state + const { rerender, getByTestId } = render( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with verified state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with expired state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with mock state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + }); +}); + +describe('IDSelectorSheet', () => { + const mockDocuments: IDSelectorDocument[] = [ + { id: 'doc1', name: 'EU ID', state: 'verified' }, + { id: 'doc2', name: 'FRA Passport', state: 'verified' }, + { id: 'doc3', name: 'Dev USA Passport', state: 'mock' }, + { id: 'doc4', name: 'Aadhaar ID', state: 'expired' }, + ]; + + const mockOnOpenChange = jest.fn(); + const mockOnSelect = jest.fn(); + const mockOnDismiss = jest.fn(); + const mockOnApprove = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders document items with testIDs', () => { + const { getByTestId } = render( + , + ); + + // Document items use Pressable which properly passes testID + expect(getByTestId('sheet-item-doc1')).toBeTruthy(); + expect(getByTestId('sheet-item-doc2')).toBeTruthy(); + expect(getByTestId('sheet-item-doc3')).toBeTruthy(); + expect(getByTestId('sheet-item-doc4')).toBeTruthy(); + }); + + it('calls onSelect when a document item is pressed', () => { + const { getByTestId } = render( + , + ); + + // Press doc2 item + fireEvent.press(getByTestId('sheet-item-doc2')); + expect(mockOnSelect).toHaveBeenCalledWith('doc2'); + }); + + it('renders empty list without document items', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('sheet-item-doc1')).toBeNull(); + expect(queryByTestId('sheet-item-doc2')).toBeNull(); + }); + + it('shows selected document as active', () => { + const { getByTestId } = render( + , + ); + + // The selected item should have the check icon (indicating active state) + expect(getByTestId('icon-check')).toBeTruthy(); + }); + + it('calls onSelect with different document IDs', () => { + const { getByTestId } = render( + , + ); + + // Press each item and verify the correct ID is passed + fireEvent.press(getByTestId('sheet-item-doc1')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc1'); + + fireEvent.press(getByTestId('sheet-item-doc2')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc2'); + + fireEvent.press(getByTestId('sheet-item-doc3')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc3'); + + fireEvent.press(getByTestId('sheet-item-doc4')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc4'); + }); +}); diff --git a/app/tests/src/hooks/useEarnPointsFlow.test.ts b/app/tests/src/hooks/useEarnPointsFlow.test.ts index 40ff79ddd..5b003e3b7 100644 --- a/app/tests/src/hooks/useEarnPointsFlow.test.ts +++ b/app/tests/src/hooks/useEarnPointsFlow.test.ts @@ -268,7 +268,7 @@ describe('useEarnPointsFlow', () => { jest.advanceTimersByTime(100); }); - expect(mockNavigate).toHaveBeenCalledWith('Prove'); + expect(mockNavigate).toHaveBeenCalledWith('ProvingScreenRouter'); }); it('should clear referrer when points disclosure modal is dismissed with referrer', async () => { diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 5f0ea721c..9a02ad058 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -62,6 +62,7 @@ describe('navigation', () => { 'DocumentNFCScan', 'DocumentNFCTrouble', 'DocumentOnboarding', + 'DocumentSelectorForProving', 'Gratification', 'Home', 'IDPicker', @@ -75,7 +76,9 @@ describe('navigation', () => { 'ProofHistory', 'ProofHistoryDetail', 'ProofRequestStatus', + 'ProofSettings', 'Prove', + 'ProvingScreenRouter', 'QRCodeTrouble', 'QRCodeViewFinder', 'RecoverWithPhrase', diff --git a/app/tests/src/navigation/deeplinks.test.ts b/app/tests/src/navigation/deeplinks.test.ts index 1c97a10b3..7ce0027e1 100644 --- a/app/tests/src/navigation/deeplinks.test.ts +++ b/app/tests/src/navigation/deeplinks.test.ts @@ -92,7 +92,7 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); expect(navigationRef.reset).toHaveBeenCalledWith({ index: 1, - routes: [{ name: 'Home' }, { name: 'Prove' }], + routes: [{ name: 'Home' }, { name: 'ProvingScreenRouter' }], }); }); @@ -118,7 +118,7 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); expect(navigationRef.reset).toHaveBeenCalledWith({ index: 1, - routes: [{ name: 'Home' }, { name: 'Prove' }], + routes: [{ name: 'Home' }, { name: 'ProvingScreenRouter' }], }); }); diff --git a/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx new file mode 100644 index 000000000..3ef5587da --- /dev/null +++ b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx @@ -0,0 +1,468 @@ +// 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 { useNavigation } from '@react-navigation/native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + getDocumentAttributes, + isDocumentValidForProving, + useSelfClient, +} from '@selfxyz/mobile-sdk-alpha'; + +import { usePassport } from '@/providers/passportDataProvider'; +import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen'; + +// Mock useFocusEffect to behave like useEffect in tests +// Note: We use a closure-based approach to avoid requiring React (prevents OOM per test-memory-optimization rules) +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + + // Track execution per component instance using a Map + const executionMap = new Map(); + + return { + ...actual, + useFocusEffect: (callback: () => void | (() => void)) => { + // Use a stable object as key - in real usage, callback is stable due to useCallback + if (!executionMap.has(callback)) { + executionMap.set(callback, true); + // Schedule callback to run after current render (simulates focus effect) + Promise.resolve().then(() => { + const cleanup = callback(); + if (typeof cleanup === 'function') { + cleanup(); + } + }); + } + }, + }; +}); + +// Mock the WalletAddressModal to avoid Modal rendering issues in tests +// Note: We return a simple string component directly to avoid requiring React (prevents OOM in CI) +jest.mock('@/components/proof-request/WalletAddressModal', () => ({ + WalletAddressModal: jest.fn(() => null), +})); + +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + useSelfClient: jest.fn(), + getDocumentAttributes: jest.fn(), + isDocumentValidForProving: jest.fn(), +})); + +jest.mock('@/providers/passportDataProvider', () => ({ + usePassport: jest.fn(), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseSelfClient = useSelfClient as jest.MockedFunction< + typeof useSelfClient +>; +const mockGetDocumentAttributes = getDocumentAttributes as jest.MockedFunction< + typeof getDocumentAttributes +>; +const mockIsDocumentValidForProving = + isDocumentValidForProving as jest.MockedFunction< + typeof isDocumentValidForProving + >; +const mockUsePassport = usePassport as jest.MockedFunction; + +type MockDocumentEntry = { + metadata: DocumentMetadata; + data: IDDocument; +}; + +const createMetadata = ( + overrides: Partial & { id: string }, +): DocumentMetadata => ({ + id: overrides.id, + documentType: overrides.documentType ?? 'us', + documentCategory: overrides.documentCategory ?? 'passport', + data: overrides.data ?? 'mock-data', + mock: overrides.mock ?? false, + isRegistered: overrides.isRegistered, + registeredAt: overrides.registeredAt, +}); + +const createDocumentEntry = ( + metadata: DocumentMetadata, + expiryDateSlice?: string, + nationalitySlice?: string, +): MockDocumentEntry => ({ + metadata, + data: { + documentType: metadata.documentType as any, + documentCategory: metadata.documentCategory as any, + mock: metadata.mock, + expiryDateSlice, + nationalitySlice, + } as unknown as IDDocument, +}); + +const createAllDocuments = (entries: MockDocumentEntry[]) => + entries.reduce< + Record + >((acc, entry) => { + acc[entry.metadata.id] = { + data: entry.data, + metadata: entry.metadata, + }; + return acc; + }, {}); + +const mockSelfApp = { + appName: 'Example App', + endpoint: 'https://example.com', + logoBase64: 'https://example.com/logo.png', + sessionId: 'session-id', + disclosures: { + name: true, + passport_number: true, + }, + userId: '0x1234567890abcdef1234567890abcdef12345678', + userIdType: 'hex', +}; + +const mockNavigate = jest.fn(); +const mockLoadDocumentCatalog = jest.fn(); +const mockGetAllDocuments = jest.fn(); +const mockSetSelectedDocument = jest.fn(); + +// Stable passport context to prevent infinite re-renders +const stablePassportContext = { + loadDocumentCatalog: mockLoadDocumentCatalog, + getAllDocuments: mockGetAllDocuments, + setSelectedDocument: mockSetSelectedDocument, +}; + +// Stable navigation object +const stableNavigation = { + navigate: mockNavigate, +}; + +// Stable self client selector function +const stableSelfAppSelector = ( + selector: (state: { selfApp: typeof mockSelfApp }) => unknown, +) => selector({ selfApp: mockSelfApp }); + +// Stable self client object +const stableSelfClient = { + useSelfAppStore: stableSelfAppSelector, +}; + +describe('DocumentSelectorForProvingScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue(stableNavigation as any); + + mockUseSelfClient.mockReturnValue(stableSelfClient as any); + + mockUsePassport.mockReturnValue(stablePassportContext as any); + + mockIsDocumentValidForProving.mockImplementation( + (_metadata, documentData) => + (documentData as { expiryDateSlice?: string } | undefined) + ?.expiryDateSlice !== 'expired', + ); + mockGetDocumentAttributes.mockImplementation((documentData: unknown) => ({ + nameSlice: '', + dobSlice: '', + yobSlice: '', + issuingStateSlice: '', + nationalitySlice: + (documentData as { nationalitySlice?: string })?.nationalitySlice || '', + passNoSlice: '', + sexSlice: '', + expiryDateSlice: + (documentData as { expiryDateSlice?: string })?.expiryDateSlice || '', + isPassportType: true, + })); + }); + + describe('Loading and Initial State', () => { + it('loads documents on mount and renders action bar', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + const { getByTestId } = render(); + + // Wait for documents to load and verify action bar buttons are rendered + // Note: Tamagui View doesn't forward testID, but Pressable children do + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve'), + ).toBeTruthy(); + expect( + getByTestId('document-selector-action-bar-document-selector'), + ).toBeTruthy(); + }); + + // Verify mocks were called + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + expect(mockGetAllDocuments).toHaveBeenCalledTimes(1); + }); + + it('renders wallet badge when userId is present', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve'), + ).toBeTruthy(); + }); + + // Wallet badge is a Pressable so testID works + expect( + getByTestId('document-selector-wallet-badge-pressable'), + ).toBeTruthy(); + }); + }); + + describe('Document Selection', () => { + it('enables approve button when valid documents exist', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + }); + + it('auto-selects first valid document when current selection is expired', async () => { + const expiredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const validCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [expiredPassport, validCard], + selectedDocumentId: 'doc-1', // Currently selected is expired + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(expiredPassport, 'expired'), + createDocumentEntry(validCard), + ]), + ); + + const { getByTestId } = render(); + + // Should auto-select the valid document (doc-2) + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Approve should select the auto-selected valid document + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-2'); + }); + }); + + it('disables approve button when only expired documents exist', async () => { + const expiredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const expiredCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [expiredPassport, expiredCard], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(expiredPassport, 'expired'), + createDocumentEntry(expiredCard, 'expired'), + ]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(true); + }); + }); + }); + + describe('Navigation and Approval', () => { + it('navigates to Prove screen after successful approval', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Press approve directly from action bar + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockNavigate).toHaveBeenCalledWith('Prove', expect.any(Object)); + }); + }); + }); + + describe('Error Handling', () => { + it('shows error state when document loading fails', async () => { + mockLoadDocumentCatalog.mockRejectedValue(new Error('failure')); + mockGetAllDocuments.mockResolvedValue({}); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render(); + + // Wait for the load to fail and verify the error was logged + await waitFor(() => { + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + }); + + // Verify error was logged (component shows error state) + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to load documents:', + expect.any(Error), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('shows error when document selection fails during approval', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + mockSetSelectedDocument.mockRejectedValue(new Error('Selection failed')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Press approve directly from action bar + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + // Verify error was logged and navigation did not occur + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to set selected document:', + expect.any(Error), + ); + }); + + expect(mockNavigate).not.toHaveBeenCalledWith( + 'Prove', + expect.any(Object), + ); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx b/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx new file mode 100644 index 000000000..027f2735a --- /dev/null +++ b/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx @@ -0,0 +1,321 @@ +// 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 { useNavigation } from '@react-navigation/native'; +import { render, waitFor } from '@testing-library/react-native'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '@selfxyz/mobile-sdk-alpha'; + +import { usePassport } from '@/providers/passportDataProvider'; +import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter'; +import { useSettingStore } from '@/stores/settingStore'; + +// Mock useFocusEffect to behave like useEffect in tests +// Note: We use jest.requireActual for React to avoid nested require() which causes OOM in CI +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + const ReactActual = jest.requireActual('react'); + return { + ...actual, + useFocusEffect: (callback: () => void) => { + ReactActual.useEffect(() => { + callback(); + }, [callback]); + }, + }; +}); + +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + isDocumentValidForProving: jest.fn(), + pickBestDocumentToSelect: jest.fn(), +})); + +jest.mock('@/providers/passportDataProvider', () => ({ + usePassport: jest.fn(), +})); + +jest.mock('@/stores/settingStore', () => ({ + useSettingStore: jest.fn(), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockIsDocumentValidForProving = + isDocumentValidForProving as jest.MockedFunction< + typeof isDocumentValidForProving + >; +const mockPickBestDocumentToSelect = + pickBestDocumentToSelect as jest.MockedFunction< + typeof pickBestDocumentToSelect + >; +const mockUsePassport = usePassport as jest.MockedFunction; +const mockUseSettingStore = useSettingStore as jest.MockedFunction< + typeof useSettingStore +>; +const mockReplace = jest.fn(); +const mockLoadDocumentCatalog = jest.fn(); +const mockGetAllDocuments = jest.fn(); +const mockSetSelectedDocument = jest.fn(); + +type MockDocumentEntry = { + metadata: DocumentMetadata; + data: IDDocument; +}; + +const createMetadata = ( + overrides: Partial & { id: string }, +): DocumentMetadata => ({ + id: overrides.id, + documentType: overrides.documentType ?? 'us', + documentCategory: overrides.documentCategory ?? 'passport', + data: overrides.data ?? 'mock-data', + mock: overrides.mock ?? false, + isRegistered: overrides.isRegistered, + registeredAt: overrides.registeredAt, +}); + +const createDocumentEntry = ( + metadata: DocumentMetadata, + expiryDateSlice?: string, +): MockDocumentEntry => ({ + metadata, + data: { + documentType: metadata.documentType as any, + documentCategory: metadata.documentCategory as any, + mock: metadata.mock, + expiryDateSlice, + } as unknown as IDDocument, +}); + +const createAllDocuments = (entries: MockDocumentEntry[]) => + entries.reduce< + Record + >((acc, entry) => { + acc[entry.metadata.id] = { + data: entry.data, + metadata: entry.metadata, + }; + return acc; + }, {}); + +describe('ProvingScreenRouter', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue({ replace: mockReplace } as any); + + mockUsePassport.mockReturnValue({ + loadDocumentCatalog: mockLoadDocumentCatalog, + getAllDocuments: mockGetAllDocuments, + setSelectedDocument: mockSetSelectedDocument, + } as any); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: false, + } as any); + + mockIsDocumentValidForProving.mockImplementation( + (_metadata, documentData) => + (documentData as { expiryDateSlice?: string } | undefined) + ?.expiryDateSlice !== 'expired', + ); + }); + + it('routes to DocumentDataNotFound when no valid documents exist', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([ + createDocumentEntry(passport, 'expired'), + ]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentDataNotFound'); + }); + }); + + it('auto-selects and routes to Prove when skipping the selector', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: true, + skipDocumentSelectorIfSingle: false, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('Prove'); + }); + }); + + it('routes to the document selector when skipping is disabled', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + }); + + it('shows error state when document loading fails', async () => { + mockLoadDocumentCatalog.mockRejectedValue(new Error('failure')); + mockGetAllDocuments.mockResolvedValue({}); + + render(); + + // Verify that the load was attempted and navigation was NOT called + await waitFor(() => { + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + }); + + // The error path should NOT navigate anywhere + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('auto-selects when skipDocumentSelectorIfSingle is true with exactly 1 valid document', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: true, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('Prove'); + }); + }); + + it('shows document selector when skipDocumentSelectorIfSingle is true with multiple valid documents', async () => { + const passport1 = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const passport2 = createMetadata({ + id: 'doc-2', + documentType: 'gb', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport1, passport2], + }; + const allDocs = createAllDocuments([ + createDocumentEntry(passport1), + createDocumentEntry(passport2), + ]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: true, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + + // Should NOT auto-select since there are multiple documents + expect(mockSetSelectedDocument).not.toHaveBeenCalled(); + }); + + it('falls back to document selector when setSelectedDocument fails', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: true, + skipDocumentSelectorIfSingle: false, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + mockSetSelectedDocument.mockRejectedValue(new Error('Selection failed')); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + }); +}); diff --git a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx index 9ee8068a8..337511349 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx @@ -254,7 +254,7 @@ export const HeldPrimaryButtonProveScreen: React.FC= expiryDateUTC; +} + +/** + * 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, + }; +} + +/** + * Extracts document attributes from passport, ID card, or Aadhaar data. + * + * @param document - Document data (PassportData, AadhaarData, or IDDocument) + * @returns Document attributes including name, DOB, expiry date, etc. + */ +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, + }; + } +} + +/** + * Checks if a document is valid for use in proving flows. + * A document is valid if it is not expired. + * Mock documents are considered valid for testing with staging environments. + * + * @param metadata - Document metadata from catalog + * @param documentData - Full document data (optional, used for expiry check) + * @returns true if document can be used for proving + */ +export function isDocumentValidForProving(metadata: DocumentMetadata, documentData?: IDDocument): boolean { + // Check if expired + if (documentData) { + try { + const attributes = getDocumentAttributes(documentData); + if (attributes.expiryDateSlice && checkDocumentExpiration(attributes.expiryDateSlice)) { + return false; + } + } catch { + // If we can't check expiry, assume valid + } + } + + return true; +} + +/** + * Picks the best document to auto-select from a catalog. + * Prefers the currently selected document if valid, otherwise picks the first valid one. + * + * @param catalog - Document catalog + * @param documents - Map of document ID to document data + * @returns Document ID to select, or undefined if no valid documents + */ +export function pickBestDocumentToSelect( + catalog: DocumentCatalog, + documents: Record, +): string | undefined { + // Check if currently selected document is valid + if (catalog.selectedDocumentId) { + const selectedMeta = catalog.documents.find(doc => doc.id === catalog.selectedDocumentId); + const selectedData = selectedMeta ? documents[catalog.selectedDocumentId] : undefined; + + if (selectedMeta && isDocumentValidForProving(selectedMeta, selectedData?.data)) { + return catalog.selectedDocumentId; + } + } + + // Find first valid document + const firstValid = catalog.documents.find(doc => { + const docData = documents[doc.id]; + return isDocumentValidForProving(doc, docData?.data); + }); + + return firstValid?.id; +} diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts index b1517f99c..7ebeae490 100644 --- a/packages/mobile-sdk-alpha/src/index.ts +++ b/packages/mobile-sdk-alpha/src/index.ts @@ -33,6 +33,8 @@ export type { BaseContext, NFCScanContext, ProofContext } from './proving/intern export type { DG1, DG2, ParsedNFCResponse } from './nfc'; +export type { DocumentAttributes } from './documents/validation'; + export type { DocumentData, DocumentMetadata, PassportCameraProps, ScreenProps } from './types/ui'; export type { HapticOptions, HapticType } from './haptic/shared'; @@ -97,7 +99,13 @@ export { triggerFeedback, } from './haptic'; -/** @deprecated Use createSelfClient().extractMRZInfo or import from './mrz' */ +export { + checkDocumentExpiration, + getDocumentAttributes, + isDocumentValidForProving, + pickBestDocumentToSelect, +} from './documents/validation'; + export { clearPassportData, getAllDocuments, @@ -114,9 +122,10 @@ export { defaultConfig } from './config/defaults'; export { defaultOptions } from './haptic/shared'; -export { extractMRZInfo, extractNameFromMRZ, formatDateToYYMMDD } from './mrz'; - +/** @deprecated Use createSelfClient().extractMRZInfo or import from './mrz' */ +export { extractMRZInfo } from './mrz'; export { extractNameFromDocument } from './documents/utils'; +export { extractNameFromMRZ, formatDateToYYMMDD } from './mrz'; export { generateMockDocument, signatureAlgorithmToStrictSignatureAlgorithm } from './mock/generator'; diff --git a/packages/mobile-sdk-alpha/tests/documents/validation.test.ts b/packages/mobile-sdk-alpha/tests/documents/validation.test.ts new file mode 100644 index 000000000..7a5001324 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/documents/validation.test.ts @@ -0,0 +1,314 @@ +// 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 { describe, expect, it } from 'vitest'; + +import type { DocumentCatalog, DocumentMetadata } from '@selfxyz/common/types'; +import type { PassportData } from '@selfxyz/common/types/passport'; + +import { + checkDocumentExpiration, + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '../../src/documents/validation'; + +describe('checkDocumentExpiration', () => { + it('returns false for invalid format (too short)', () => { + expect(checkDocumentExpiration('1234')).toBe(false); + }); + + it('returns false for invalid format (too long)', () => { + expect(checkDocumentExpiration('1234567')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(checkDocumentExpiration('')).toBe(false); + }); + + it('returns true for expired date (past date)', () => { + // Date in 2020 + expect(checkDocumentExpiration('200101')).toBe(true); + }); + + it('returns false for future date', () => { + // Date in 2050 + expect(checkDocumentExpiration('500101')).toBe(false); + }); + + it('returns true for today (expired as of today)', () => { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const day = now.getDate().toString().padStart(2, '0'); + const today = `${year}${month}${day}`; + // Document that expires today is considered expired + expect(checkDocumentExpiration(today)).toBe(true); + }); + + it('returns true for yesterday (expired)', () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const year = yesterday.getFullYear().toString().slice(-2); + const month = (yesterday.getMonth() + 1).toString().padStart(2, '0'); + const day = yesterday.getDate().toString().padStart(2, '0'); + const yesterdayStr = `${year}${month}${day}`; + expect(checkDocumentExpiration(yesterdayStr)).toBe(true); + }); +}); + +describe('isDocumentValidForProving', () => { + const mockMetadata: DocumentMetadata = { + id: 'test-id', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: false, + }; + + it('returns true for document without data (cannot check expiry)', () => { + expect(isDocumentValidForProving(mockMetadata)).toBe(true); + }); + + it('returns true for mock document', () => { + const mockDoc: DocumentMetadata = { + ...mockMetadata, + mock: true, + }; + expect(isDocumentValidForProving(mockDoc)).toBe(true); + }); + + it('returns true for valid passport with future expiry', () => { + // MRZ with expiry date 501231 (December 31, 2050) + const validPassport: PassportData = { + mrz: 'P { + // Passport expired in 2012 + const expiredPassport: PassportData = { + mrz: 'P { + const invalidDocument = { + documentType: 'passport', + documentCategory: 'passport', + mock: false, + } as any; + + expect(isDocumentValidForProving(mockMetadata, invalidDocument)).toBe(true); + }); +}); + +describe('pickBestDocumentToSelect', () => { + // MRZ with expiry date 501231 (December 31, 2050) + const validPassport: PassportData = { + mrz: 'P { + const catalog: DocumentCatalog = { + documents: [], + }; + expect(pickBestDocumentToSelect(catalog, {})).toBeUndefined(); + }); + + it('returns currently selected document if valid', () => { + const metadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata], + selectedDocumentId: 'doc1', + }; + + const documents = { + doc1: { data: validPassport, metadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('returns first valid document if currently selected is expired', () => { + const expiredMetadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const validMetadata: DocumentMetadata = { + id: 'doc2', + documentType: 'passport', + documentCategory: 'passport', + data: 'data2', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [expiredMetadata, validMetadata], + selectedDocumentId: 'doc1', + }; + + const documents = { + doc1: { data: expiredPassport, metadata: expiredMetadata }, + doc2: { data: validPassport, metadata: validMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc2'); + }); + + it('returns first valid document if no document is selected', () => { + const metadata1: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const metadata2: DocumentMetadata = { + id: 'doc2', + documentType: 'passport', + documentCategory: 'passport', + data: 'data2', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata1, metadata2], + }; + + const documents = { + doc1: { data: validPassport, metadata: metadata1 }, + doc2: { data: validPassport, metadata: metadata2 }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('returns undefined if all documents are expired', () => { + const metadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata], + }; + + const documents = { + doc1: { data: expiredPassport, metadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBeUndefined(); + }); + + it('selects mock document if it is the only option', () => { + const mockMetadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: true, + }; + + const catalog: DocumentCatalog = { + documents: [mockMetadata], + }; + + const mockPassport: PassportData = { + ...validPassport, + mock: true, + }; + + const documents = { + doc1: { data: mockPassport, metadata: mockMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('prefers selected document even if it is mock', () => { + const mockMetadata: DocumentMetadata = { + id: 'mock1', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: true, + }; + + const realMetadata: DocumentMetadata = { + id: 'real1', + documentType: 'passport', + documentCategory: 'passport', + data: 'real-data', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [mockMetadata, realMetadata], + selectedDocumentId: 'mock1', + }; + + const mockPassport: PassportData = { + ...validPassport, + mock: true, + }; + + const documents = { + mock1: { data: mockPassport, metadata: mockMetadata }, + real1: { data: validPassport, metadata: realMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('mock1'); + }); +}); From e1d06b5b1330494312a4a6639a961a18e6666047 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Sat, 10 Jan 2026 11:29:02 +0530 Subject: [PATCH 19/47] Feat: keychain modal (#1564) * feat: show Modal on keychain error * update message * yarn fmt * fix tests * fix ci --- app/src/providers/authProvider.tsx | 112 ++++++++++++++---- app/src/providers/passportDataProvider.tsx | 73 ++++++++++++ app/src/providers/selfClientProvider.tsx | 69 ++++++++++- packages/mobile-sdk-alpha/src/client.ts | 1 + .../src/proving/provingMachine.ts | 10 +- packages/mobile-sdk-alpha/src/types/public.ts | 3 + 6 files changed, 242 insertions(+), 26 deletions(-) diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 40be9fb47..66e0ccca6 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -147,31 +147,96 @@ async function restoreFromMnemonic( } } +let keychainCryptoFailureCallback: + | ((errorType: 'user_cancelled' | 'crypto_failed') => void) + | null = null; + +function isUserCancellation(error: unknown): boolean { + const err = error as { code?: string; message?: string }; + return Boolean( + err?.code === 'E_AUTHENTICATION_FAILED' || + err?.code === 'USER_CANCELED' || + err?.message?.includes('User canceled') || + err?.message?.includes('Authentication canceled') || + err?.message?.includes('cancelled by user'), + ); +} + +function isKeychainCryptoError(error: unknown): boolean { + const err = error as { code?: string; name?: string; message?: string }; + return Boolean( + (err?.code === 'E_CRYPTO_FAILED' || + err?.name === 'com.oblador.keychain.exceptions.CryptoFailedException' || + err?.message?.includes('CryptoFailedException') || + err?.message?.includes('Decryption failed') || + err?.message?.includes('Authentication tag verification failed')) && + !isUserCancellation(error), + ); +} + async function loadOrCreateMnemonic( keychainOptions: KeychainOptions, ): Promise { // Get adaptive security configuration const { setOptions, getOptions } = keychainOptions; - const storedMnemonic = await Keychain.getGenericPassword({ - ...getOptions, - service: SERVICE_NAME, - }); - if (storedMnemonic) { - try { - JSON.parse(storedMnemonic.password); - trackEvent(AuthEvents.MNEMONIC_LOADED); - return storedMnemonic.password; - } catch (e: unknown) { - console.error( - 'Error parsing stored mnemonic, old secret format was used', - e, - ); - trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, { - reason: 'unknown_error', - error: e instanceof Error ? e.message : String(e), - }); + try { + const storedMnemonic = await Keychain.getGenericPassword({ + ...getOptions, + service: SERVICE_NAME, + }); + if (storedMnemonic) { + try { + JSON.parse(storedMnemonic.password); + trackEvent(AuthEvents.MNEMONIC_LOADED); + return storedMnemonic.password; + } catch (e: unknown) { + console.error( + 'Error parsing stored mnemonic, old secret format was used', + e, + ); + trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, { + reason: 'unknown_error', + error: e instanceof Error ? e.message : String(e), + }); + } } + } catch (error: unknown) { + if (isUserCancellation(error)) { + console.log('User cancelled authentication'); + trackEvent(AuthEvents.BIOMETRIC_LOGIN_CANCELLED); + + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback('user_cancelled'); + } + + throw error; + } + + if (isKeychainCryptoError(error)) { + const err = error as { code?: string; name?: string }; + console.error('Keychain crypto error:', { + code: err?.code, + name: err?.name, + }); + trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, { + reason: 'keychain_crypto_failed', + errorCode: err?.code, + }); + + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback('crypto_failed'); + } + + throw error; + } + + console.error('Error loading mnemonic:', error); + trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, { + reason: 'unknown_error', + error: error instanceof Error ? error.message : String(error), + }); + throw error; } try { const { mnemonic } = ethers.HDNodeWallet.fromMnemonic( @@ -424,6 +489,13 @@ export async function migrateToSecureKeychain(): Promise { } } +// Global callback for keychain crypto failures +export function setKeychainCryptoFailureCallback( + callback: ((errorType: 'user_cancelled' | 'crypto_failed') => void) | null, +) { + keychainCryptoFailureCallback = callback; +} + export async function unsafe_clearSecrets() { if (__DEV__) { await Keychain.resetGenericPassword({ service: SERVICE_NAME }); @@ -456,10 +528,6 @@ export async function unsafe_getPointsPrivateKey( return wallet.privateKey; } -/** - * The only reason this is exported without being locked behind user biometrics is to allow `loadPassportDataAndSecret` - * to access both the privatekey and the passport data with the user only authenticating once - */ export async function unsafe_getPrivateKey(keychainOptions?: KeychainOptions) { const options = keychainOptions || diff --git a/app/src/providers/passportDataProvider.tsx b/app/src/providers/passportDataProvider.tsx index 8ce305cff..df0a1c11a 100644 --- a/app/src/providers/passportDataProvider.tsx +++ b/app/src/providers/passportDataProvider.tsx @@ -68,6 +68,41 @@ import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { createKeychainOptions } from '@/integrations/keychain'; import { unsafe_getPrivateKey, useAuth } from '@/providers/authProvider'; +let keychainCryptoFailureCallback: + | ((errorType: 'user_cancelled' | 'crypto_failed') => void) + | null = null; + +export function setPassportKeychainErrorCallback( + callback: ((errorType: 'user_cancelled' | 'crypto_failed') => void) | null, +) { + keychainCryptoFailureCallback = callback; +} + +function isUserCancellation(error: unknown): boolean { + const err = error as { code?: string; message?: string }; + // User cancelled biometric/PIN authentication + return Boolean( + err?.code === 'E_AUTHENTICATION_FAILED' || + err?.code === 'USER_CANCELED' || + err?.message?.includes('User canceled') || + err?.message?.includes('Authentication canceled') || + err?.message?.includes('cancelled by user'), + ); +} + +function isKeychainCryptoError(error: unknown): boolean { + const err = error as { code?: string; name?: string; message?: string }; + // Only true crypto failures, not user cancellations + return Boolean( + (err?.code === 'E_CRYPTO_FAILED' || + err?.name === 'com.oblador.keychain.exceptions.CryptoFailedException' || + err?.message?.includes('CryptoFailedException') || + err?.message?.includes('Decryption failed') || + err?.message?.includes('Authentication tag verification failed')) && + !isUserCancellation(error), + ); +} + // Create safe wrapper functions to prevent undefined errors during early initialization // These need to be declared early to avoid dependency issues const safeLoadDocumentCatalog = async (): Promise => { @@ -447,6 +482,24 @@ export async function loadDocumentByIdDirectlyFromKeychain( return JSON.parse(documentCreds.password); } } catch (error) { + if (isUserCancellation(error)) { + console.log(`User cancelled authentication for document ${documentId}`); + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback('user_cancelled'); + } + } + + if (isKeychainCryptoError(error)) { + const err = error as { code?: string; name?: string }; + console.error(`Keychain crypto error loading document ${documentId}:`, { + code: err?.code, + name: err?.name, + }); + + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback('crypto_failed'); + } + } console.log(`Error loading document ${documentId}:`, error); } return null; @@ -491,6 +544,26 @@ export async function loadDocumentCatalogDirectlyFromKeychain(): Promise { } } }, + enableKeychainErrorModal, + disableKeychainErrorModal, }, crypto: { async hash( @@ -322,4 +334,55 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { ); }; +export function disableKeychainErrorModal() { + setKeychainCryptoFailureCallback(null); + setPassportKeychainErrorCallback(null); +} + +// Functions to enable/disable keychain error modals +// These should be called by the provingMachine when entering/exiting proving flows +export function enableKeychainErrorModal() { + setKeychainCryptoFailureCallback(showKeychainErrorModal); + setPassportKeychainErrorCallback(showKeychainErrorModal); +} + +export function showKeychainErrorModal( + errorType: 'user_cancelled' | 'crypto_failed', +) { + if (!navigationRef.isReady()) return; + + const errorContent = { + user_cancelled: { + titleText: 'Authentication Required', + bodyText: + 'You need to authenticate with your fingerprint, PIN or faceID to continue the verification process. Please try again.', + buttonText: 'Try Again', + }, + crypto_failed: { + titleText: 'Keychain Error', + bodyText: + 'Unable to access your keychain. This may happen if your device security settings have changed or if the encrypted data was corrupted. Please contact support if the issue persists.', + buttonText: 'Go to Home', + }, + }; + + const content = errorContent[errorType]; + + const callbackId = registerModalCallbacks({ + onButtonPress: () => { + unregisterModalCallbacks(callbackId); + navigationRef.navigate({ name: 'Home', params: {} }); + }, + onModalDismiss: () => { + unregisterModalCallbacks(callbackId); + navigationRef.navigate({ name: 'Home', params: {} }); + }, + }); + + navigationRef.navigate('Modal', { + ...content, + callbackId, + }); +} + export default SelfClientProvider; diff --git a/packages/mobile-sdk-alpha/src/client.ts b/packages/mobile-sdk-alpha/src/client.ts index a03337ad3..5903a7b8f 100644 --- a/packages/mobile-sdk-alpha/src/client.ts +++ b/packages/mobile-sdk-alpha/src/client.ts @@ -218,6 +218,7 @@ export function createSelfClient({ goTo: (routeName, params) => { adapters.navigation.goTo(routeName, params); }, + navigation: adapters.navigation, // for reactivity (if needed) useProvingStore, useSelfAppStore, diff --git a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts index 50ffd4e6b..0aa503dfe 100644 --- a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts +++ b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts @@ -458,6 +458,9 @@ export const useProvingStore = create((set, get) => { if (get().circuitType === 'disclose') { selfClient.getSelfAppState().handleProofResult(true); } + + // Disable keychain error modal when proving flow ends + selfClient.navigation?.disableKeychainErrorModal?.(); } if (state.value === 'passport_not_supported') { @@ -482,6 +485,8 @@ export const useProvingStore = create((set, get) => { if (get().circuitType === 'disclose') { selfClient.getSelfAppState().handleProofResult(false, 'error', 'error'); } + // Disable keychain error modal when proving flow ends + selfClient.navigation?.disableKeychainErrorModal?.(); } }); } @@ -844,6 +849,10 @@ export const useProvingStore = create((set, get) => { selfClient.trackEvent(ProofEvents.PROVING_INIT); get()._closeConnections(selfClient); + // Enable keychain error modal for proving flows + // This ensures users are notified if keychain access fails during critical operations + selfClient.navigation?.enableKeychainErrorModal?.(); + if (actor) { try { actor.stop(); @@ -884,7 +893,6 @@ export const useProvingStore = create((set, get) => { } const { data: passportData } = selectedDocument; - const secret = await selfClient.getPrivateKey(); if (!secret) { console.error('Could not load secret'); diff --git a/packages/mobile-sdk-alpha/src/types/public.ts b/packages/mobile-sdk-alpha/src/types/public.ts index c24c40a61..df74c79a1 100644 --- a/packages/mobile-sdk-alpha/src/types/public.ts +++ b/packages/mobile-sdk-alpha/src/types/public.ts @@ -229,6 +229,8 @@ export type RouteName = export interface NavigationAdapter { goBack(): void; goTo(routeName: RouteName, params?: Record): void; + enableKeychainErrorModal?(): void; + disableKeychainErrorModal?(): void; } /** @@ -316,6 +318,7 @@ export interface SelfClient { extractMRZInfo(mrz: string): MRZInfo; goBack(): void; goTo(routeName: RouteName, params?: Record): void; + navigation: NavigationAdapter; /** * Convenience wrapper around {@link AnalyticsAdapter.trackEvent}. Calls are From 3ce1f26898e3b57935e6bd32a195e92237b7339b Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Fri, 9 Jan 2026 23:52:36 -0800 Subject: [PATCH 20/47] SELF-1754: polish for document selection for proving flow (#1570) * change spinner color * fix disclosure scroll feedback * fix type error * fix button scrolling logic * make the connected wallet static * fix hold to verify button feedback timing * formatting * clean up tex --- .../proof-request/BottomVerifyBar.tsx | 3 + .../proof-request/ProofRequestCard.tsx | 53 +++++++++++----- .../DocumentSelectorForProvingScreen.tsx | 34 +++++------ app/src/screens/verification/ProveScreen.tsx | 44 +++++++------ .../verification/ProvingScreenRouter.tsx | 4 +- app/src/stores/database.ts | 9 ++- app/tests/src/stores/database.test.ts | 37 +++++++++++ .../src/stores/proofHistoryStore.test.ts | 29 +++++++++ .../buttons/HeldPrimaryButtonProveScreen.tsx | 61 ++++++++----------- 9 files changed, 184 insertions(+), 90 deletions(-) diff --git a/app/src/components/proof-request/BottomVerifyBar.tsx b/app/src/components/proof-request/BottomVerifyBar.tsx index 3c57a162d..0106b7cce 100644 --- a/app/src/components/proof-request/BottomVerifyBar.tsx +++ b/app/src/components/proof-request/BottomVerifyBar.tsx @@ -14,6 +14,7 @@ export interface BottomVerifyBarProps { onVerify: () => void; selectedAppSessionId: string | undefined | null; hasScrolledToBottom: boolean; + isScrollable: boolean; isReadyToProve: boolean; isDocumentExpired: boolean; testID?: string; @@ -23,6 +24,7 @@ export const BottomVerifyBar: React.FC = ({ onVerify, selectedAppSessionId, hasScrolledToBottom, + isScrollable, isReadyToProve, isDocumentExpired, testID = 'bottom-verify-bar', @@ -41,6 +43,7 @@ export const BottomVerifyBar: React.FC = ({ onVerify={onVerify} selectedAppSessionId={selectedAppSessionId} hasScrolledToBottom={hasScrolledToBottom} + isScrollable={isScrollable} isReadyToProve={isReadyToProve} isDocumentExpired={isDocumentExpired} /> diff --git a/app/src/components/proof-request/ProofRequestCard.tsx b/app/src/components/proof-request/ProofRequestCard.tsx index 7d50c734e..9d30dd389 100644 --- a/app/src/components/proof-request/ProofRequestCard.tsx +++ b/app/src/components/proof-request/ProofRequestCard.tsx @@ -32,6 +32,7 @@ export interface ProofRequestCardProps { documentType?: string; timestamp?: Date; children?: React.ReactNode; + connectedWalletBadge?: React.ReactNode; testID?: string; onScroll?: (event: NativeSyntheticEvent) => void; scrollViewRef?: React.RefObject; @@ -52,6 +53,7 @@ export const ProofRequestCard: React.FC = ({ documentType = '', timestamp, children, + connectedWalletBadge, testID = 'proof-request-card', onScroll, scrollViewRef, @@ -111,26 +113,47 @@ export const ProofRequestCard: React.FC = ({ - + {connectedWalletBadge} + + )} + + {/* Scrollable Content */} + - {children} - + + {children} + + diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx index 12ce5a348..dfcaeea67 100644 --- a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -30,7 +30,7 @@ import { isDocumentValidForProving, useSelfClient, } from '@selfxyz/mobile-sdk-alpha'; -import { blue600, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; import type { IDSelectorState } from '@/components/documents'; @@ -341,7 +341,7 @@ const DocumentSelectorForProvingScreen: React.FC = () => { justifyContent="center" testID="document-selector-loading-container" > - + ); } @@ -418,25 +418,25 @@ const DocumentSelectorForProvingScreen: React.FC = () => { appName={selfApp?.appName || 'Self'} appUrl={url} documentType={selectedDocumentType} + connectedWalletBadge={ + formattedUserId ? ( + setWalletModalOpen(true)} + testID="document-selector-wallet-badge" + /> + ) : undefined + } onScroll={handleScroll} testID="document-selector-card" > - {/* Connected Wallet Badge */} - {formattedUserId && ( - setWalletModalOpen(true)} - testID="document-selector-wallet-badge" - /> - )} - {/* Disclosure Items */} - + {disclosureItems.map((item, index) => ( { const scrollViewRef = useRef(null); const isContentShorterThanScrollView = useMemo( - () => scrollViewContentHeight <= scrollViewHeight, + () => scrollViewContentHeight <= scrollViewHeight + 50, [scrollViewContentHeight, scrollViewHeight], ); + + const isScrollable = useMemo( + () => !isContentShorterThanScrollView && hasLayoutMeasurements, + [isContentShorterThanScrollView, hasLayoutMeasurements], + ); const provingStore = useProvingStore(); const currentState = useProvingStore(state => state.currentState); const isReadyToProve = currentState === 'ready_to_prove'; @@ -255,7 +260,7 @@ const ProveScreen: React.FC = () => { } const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; - const paddingToBottom = 10; + const paddingToBottom = 50; const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom; @@ -287,7 +292,7 @@ const ProveScreen: React.FC = () => { // If we now have both measurements and content fits on screen, enable button immediately if (contentHeight > 0 && scrollViewHeight > 0) { setHasLayoutMeasurements(true); - if (contentHeight <= scrollViewHeight) { + if (contentHeight <= scrollViewHeight + 50) { setHasScrolledToBottom(true); } } @@ -302,7 +307,7 @@ const ProveScreen: React.FC = () => { // If we now have both measurements and content fits on screen, enable button immediately if (layoutHeight > 0 && scrollViewContentHeight > 0) { setHasLayoutMeasurements(true); - if (scrollViewContentHeight <= layoutHeight) { + if (scrollViewContentHeight <= layoutHeight + 50) { setHasScrolledToBottom(true); } } @@ -317,6 +322,20 @@ const ProveScreen: React.FC = () => { appName={selectedApp?.appName || 'Self'} appUrl={url} documentType={documentType} + connectedWalletBadge={ + formattedUserId ? ( + setWalletModalOpen(true)} + testID="prove-screen-wallet-badge" + /> + ) : undefined + } onScroll={handleScroll} scrollViewRef={scrollViewRef} onContentSizeChange={handleContentSizeChange} @@ -324,20 +343,8 @@ const ProveScreen: React.FC = () => { initialScrollOffset={route.params?.scrollOffset} testID="prove-screen-card" > - {formattedUserId && ( - setWalletModalOpen(true)} - testID="prove-screen-wallet-badge" - /> - )} - - + {/* Disclosure Items */} + {disclosureItems.map((item, index) => ( { onVerify={onVerify} selectedAppSessionId={selectedApp?.sessionId} hasScrolledToBottom={hasScrolledToBottom} + isScrollable={isScrollable} isReadyToProve={isReadyToProve} isDocumentExpired={isDocumentExpired} testID="prove-screen-verify-bar" diff --git a/app/src/screens/verification/ProvingScreenRouter.tsx b/app/src/screens/verification/ProvingScreenRouter.tsx index dc589853a..058297d20 100644 --- a/app/src/screens/verification/ProvingScreenRouter.tsx +++ b/app/src/screens/verification/ProvingScreenRouter.tsx @@ -12,7 +12,7 @@ import { isDocumentValidForProving, pickBestDocumentToSelect, } from '@selfxyz/mobile-sdk-alpha'; -import { blue600 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; import { proofRequestColors } from '@/components/proof-request'; @@ -195,7 +195,7 @@ const ProvingScreenRouter: React.FC = () => { ) : ( <> - + )} diff --git a/app/src/stores/database.ts b/app/src/stores/database.ts index 717420019..87915d323 100644 --- a/app/src/stores/database.ts +++ b/app/src/stores/database.ts @@ -127,8 +127,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: insertResult.insertId ? insertResult.insertId.toString() : '0', timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -154,8 +155,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: insertResult.insertId ? insertResult.insertId.toString() : '0', timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -182,8 +184,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: insertResult.insertId ? insertResult.insertId.toString() : '0', timestamp, rowsAffected: insertResult.rowsAffected, }; diff --git a/app/tests/src/stores/database.test.ts b/app/tests/src/stores/database.test.ts index 5d7807eb8..5f6293e74 100644 --- a/app/tests/src/stores/database.test.ts +++ b/app/tests/src/stores/database.test.ts @@ -173,6 +173,43 @@ describe('database (SQLite)', () => { rowsAffected: 1, }); }); + + it('handles duplicate sessionId gracefully (INSERT OR IGNORE skips)', async () => { + const mockProof = { + appName: 'TestApp', + sessionId: 'session-123', + userId: 'user-456', + userIdType: 'uuid' as const, + endpointType: 'https' as const, + status: ProofStatus.PENDING, + disclosures: '{"test": "data"}', + logoBase64: 'base64-logo', + documentId: 'document-123', + endpoint: 'https://example.com/endpoint', + }; + + // Simulate INSERT OR IGNORE behavior when a duplicate sessionId exists + const mockInsertResult = { + insertId: 0, // SQLite returns 0 for ignored inserts + rowsAffected: 0, + }; + + mockDb.executeSql.mockResolvedValueOnce([mockInsertResult]); + + const result = await database.insertProof(mockProof); + + expect(mockDb.executeSql).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO proof_history'), + expect.any(Array), + ); + + // Should handle undefined/0 insertId gracefully + expect(result).toEqual({ + id: '0', + timestamp: expect.any(Number), + rowsAffected: 0, + }); + }); }); describe('updateProofStatus', () => { diff --git a/app/tests/src/stores/proofHistoryStore.test.ts b/app/tests/src/stores/proofHistoryStore.test.ts index 10d2c46d0..c34bf90ff 100644 --- a/app/tests/src/stores/proofHistoryStore.test.ts +++ b/app/tests/src/stores/proofHistoryStore.test.ts @@ -156,6 +156,35 @@ describe('proofHistoryStore', () => { expect(mockDatabase.insertProof).toHaveBeenCalledWith(mockProof); expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0); }); + + it('handles duplicate insertion gracefully (rowsAffected = 0)', async () => { + const mockProof = { + appName: 'TestApp', + sessionId: 'session-123', + userId: 'user-456', + userIdType: 'uuid', + endpointType: 'celo', + status: ProofStatus.PENDING, + disclosures: '{"test": "data"}', + } as const; + + // Simulate INSERT OR IGNORE skipping the insertion due to duplicate sessionId + const mockInsertResult = { + id: '0', + timestamp: Date.now(), + rowsAffected: 0, + }; + + mockDatabase.insertProof.mockResolvedValue(mockInsertResult); + + await act(async () => { + await useProofHistoryStore.getState().addProofHistory(mockProof); + }); + + expect(mockDatabase.insertProof).toHaveBeenCalledWith(mockProof); + // Should not add to store when rowsAffected is 0 + expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0); + }); }); describe('updateProofStatus', () => { diff --git a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx index 337511349..352997407 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx @@ -18,6 +18,7 @@ interface HeldPrimaryButtonProveScreenProps { onVerify: () => void; selectedAppSessionId: string | undefined | null; hasScrolledToBottom: boolean; + isScrollable: boolean; isReadyToProve: boolean; isDocumentExpired: boolean; } @@ -76,7 +77,11 @@ const buttonMachine = createMachine( }, { target: 'preparing', - guard: ({ context }) => context.hasScrolledToBottom, + guard: ({ context }) => context.hasScrolledToBottom && !context.isReadyToProve, + }, + { + target: 'ready', + guard: ({ context }) => context.hasScrolledToBottom && context.isReadyToProve && !context.isDocumentExpired, }, ], }, @@ -96,7 +101,7 @@ const buttonMachine = createMachine( }, ], after: { - 500: { target: 'preparing2' }, + 100: { target: 'preparing2' }, }, }, preparing2: { @@ -115,7 +120,7 @@ const buttonMachine = createMachine( }, ], after: { - 500: { target: 'preparing3' }, + 100: { target: 'preparing3' }, }, }, preparing3: { @@ -195,6 +200,7 @@ export const HeldPrimaryButtonProveScreen: React.FC { @@ -212,57 +218,42 @@ export const HeldPrimaryButtonProveScreen: React.FC = ({ text }) => ( + + + {text} + + ); const renderButtonContent = () => { if (isDocumentExpired) { return 'Document expired'; } if (state.matches('waitingForSession')) { - return ( - - - Waiting for app... - - ); + return ; } if (state.matches('needsScroll')) { - return 'Please read all disclosures'; + if (isScrollable) { + return 'Scroll to read full request'; + } + return ; } if (state.matches('preparing')) { - return ( - - - Accessing to Keychain data - - ); + return ; } if (state.matches('preparing2')) { - return ( - - - Parsing passport data - - ); + return ; } if (state.matches('preparing3')) { - return ( - - - Preparing for verification - - ); + return ; } if (state.matches('ready')) { return 'Press and hold to verify'; } if (state.matches('verifying')) { - return ( - - - Generating proof - - ); + return ; } return null; }; From dd474a12f372575b96de10329ec0a393bc454a8d Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sat, 10 Jan 2026 00:37:42 -0800 Subject: [PATCH 21/47] Refactor insert id helper (#1573) --- app/src/stores/database.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/stores/database.ts b/app/src/stores/database.ts index 87915d323..76561008d 100644 --- a/app/src/stores/database.ts +++ b/app/src/stores/database.ts @@ -14,6 +14,9 @@ const STALE_PROOF_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes SQLite.enablePromise(true); +const toInsertId = (result: SQLite.ResultSet) => + result.insertId ? result.insertId.toString() : '0'; + async function openDatabase() { return SQLite.openDatabase({ name: DB_NAME, @@ -129,7 +132,7 @@ export const database: ProofDB = { ); // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId ? insertResult.insertId.toString() : '0', + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -157,7 +160,7 @@ export const database: ProofDB = { ); // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId ? insertResult.insertId.toString() : '0', + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -186,7 +189,7 @@ export const database: ProofDB = { ); // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId ? insertResult.insertId.toString() : '0', + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; From 9d4c62225fe29c5dcf3656a7d0bae0f00ce4c6f9 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sat, 10 Jan 2026 00:42:15 -0800 Subject: [PATCH 22/47] Rename staleness hook file to `useProofDisclosureStalenessCheck` and add unit tests (#1572) * Add staleness hook test * format --- ...ts => useProofDisclosureStalenessCheck.ts} | 2 +- .../DocumentSelectorForProvingScreen.tsx | 4 +- app/src/screens/verification/ProveScreen.tsx | 4 +- .../useProofDisclosureStalenessCheck.test.ts | 85 +++++++++++++++++++ 4 files changed, 90 insertions(+), 5 deletions(-) rename app/src/hooks/{useSelfAppStalenessCheck.ts => useProofDisclosureStalenessCheck.ts} (96%) create mode 100644 app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts diff --git a/app/src/hooks/useSelfAppStalenessCheck.ts b/app/src/hooks/useProofDisclosureStalenessCheck.ts similarity index 96% rename from app/src/hooks/useSelfAppStalenessCheck.ts rename to app/src/hooks/useProofDisclosureStalenessCheck.ts index 89614c9cb..560d53b99 100644 --- a/app/src/hooks/useSelfAppStalenessCheck.ts +++ b/app/src/hooks/useProofDisclosureStalenessCheck.ts @@ -17,7 +17,7 @@ import type { RootStackParamList } from '@/navigation'; * Uses a small delay to allow store updates to propagate after navigation * (e.g., after QR code scan sets selfApp data). */ -export function useSelfAppStalenessCheck( +export function useProofDisclosureStalenessCheck( selfApp: SelfApp | null, disclosureItems: Array<{ key: string; text: string }>, navigation: NativeStackNavigationProp, diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx index dfcaeea67..7fe03c08f 100644 --- a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -44,8 +44,8 @@ import { truncateAddress, WalletAddressModal, } from '@/components/proof-request'; +import { useProofDisclosureStalenessCheck } from '@/hooks/useProofDisclosureStalenessCheck'; import { useSelfAppData } from '@/hooks/useSelfAppData'; -import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck'; import type { RootStackParamList } from '@/navigation'; import { usePassport } from '@/providers/passportDataProvider'; import { getDocumentTypeName } from '@/utils/documentUtils'; @@ -121,7 +121,7 @@ const DocumentSelectorForProvingScreen: React.FC = () => { useSelfAppData(selfApp); // Check for stale data and navigate to Home if needed - useSelfAppStalenessCheck(selfApp, disclosureItems, navigation); + useProofDisclosureStalenessCheck(selfApp, disclosureItems, navigation); const [documentCatalog, setDocumentCatalog] = useState({ documents: [], diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 5cc097bc3..3f3df8e5d 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -38,8 +38,8 @@ import { truncateAddress, WalletAddressModal, } from '@/components/proof-request'; +import { useProofDisclosureStalenessCheck } from '@/hooks/useProofDisclosureStalenessCheck'; import { useSelfAppData } from '@/hooks/useSelfAppData'; -import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck'; import { buttonTap } from '@/integrations/haptics'; import type { RootStackParamList } from '@/navigation'; import { @@ -74,7 +74,7 @@ const ProveScreen: React.FC = () => { useSelfAppData(selectedApp); // Check for stale data and navigate to Home if needed - useSelfAppStalenessCheck( + useProofDisclosureStalenessCheck( selectedApp, disclosureItems, navigation as NativeStackNavigationProp, diff --git a/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts b/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts new file mode 100644 index 000000000..f9a828d3d --- /dev/null +++ b/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts @@ -0,0 +1,85 @@ +// 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 { act, renderHook } from '@testing-library/react-native'; + +import type { SelfApp } from '@selfxyz/common'; + +import { useProofDisclosureStalenessCheck } from '@/hooks/useProofDisclosureStalenessCheck'; + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (callback: () => void | (() => void)) => { + callback(); + }, +})); + +describe('useProofDisclosureStalenessCheck', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('navigates home when selfApp is missing', () => { + const navigation = { navigate: jest.fn() }; + + renderHook(() => + useProofDisclosureStalenessCheck( + null, + [{ key: 'a', text: 'Disclosure' }], + navigation as any, + ), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).toHaveBeenCalledWith({ + name: 'Home', + params: {}, + }); + }); + + it('navigates home when disclosure items are empty', () => { + const navigation = { navigate: jest.fn() }; + const selfApp = { appName: 'Test App' } as unknown as SelfApp; + + renderHook(() => + useProofDisclosureStalenessCheck(selfApp, [], navigation as any), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).toHaveBeenCalledWith({ + name: 'Home', + params: {}, + }); + }); + + it('does not navigate when data is present', () => { + const navigation = { navigate: jest.fn() }; + const selfApp = { appName: 'Test App' } as unknown as SelfApp; + + renderHook(() => + useProofDisclosureStalenessCheck( + selfApp, + [{ key: 'a', text: 'Disclosure' }], + navigation as any, + ), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).not.toHaveBeenCalled(); + }); +}); From 665545cd656b01c05b796cdc8975910c8b85b32e Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sat, 10 Jan 2026 00:43:36 -0800 Subject: [PATCH 23/47] Centralize keychain error helpers and add unit tests (#1571) * Add keychain error tests * format --- app/src/providers/authProvider.tsx | 30 ++---- app/src/providers/passportDataProvider.tsx | 109 +++++++++------------ app/src/utils/keychainErrors.ts | 46 +++++++++ app/tests/src/utils/keychainErrors.test.ts | 64 ++++++++++++ 4 files changed, 164 insertions(+), 85 deletions(-) create mode 100644 app/src/utils/keychainErrors.ts create mode 100644 app/tests/src/utils/keychainErrors.test.ts diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 66e0ccca6..bfcdf8023 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -25,6 +25,11 @@ import { import { trackEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; import type { Mnemonic } from '@/types/mnemonic'; +import { + getKeychainErrorIdentity, + isKeychainCryptoError, + isUserCancellation, +} from '@/utils/keychainErrors'; const SERVICE_NAME = 'secret'; @@ -151,29 +156,6 @@ let keychainCryptoFailureCallback: | ((errorType: 'user_cancelled' | 'crypto_failed') => void) | null = null; -function isUserCancellation(error: unknown): boolean { - const err = error as { code?: string; message?: string }; - return Boolean( - err?.code === 'E_AUTHENTICATION_FAILED' || - err?.code === 'USER_CANCELED' || - err?.message?.includes('User canceled') || - err?.message?.includes('Authentication canceled') || - err?.message?.includes('cancelled by user'), - ); -} - -function isKeychainCryptoError(error: unknown): boolean { - const err = error as { code?: string; name?: string; message?: string }; - return Boolean( - (err?.code === 'E_CRYPTO_FAILED' || - err?.name === 'com.oblador.keychain.exceptions.CryptoFailedException' || - err?.message?.includes('CryptoFailedException') || - err?.message?.includes('Decryption failed') || - err?.message?.includes('Authentication tag verification failed')) && - !isUserCancellation(error), - ); -} - async function loadOrCreateMnemonic( keychainOptions: KeychainOptions, ): Promise { @@ -214,7 +196,7 @@ async function loadOrCreateMnemonic( } if (isKeychainCryptoError(error)) { - const err = error as { code?: string; name?: string }; + const err = getKeychainErrorIdentity(error); console.error('Keychain crypto error:', { code: err?.code, name: err?.name, diff --git a/app/src/providers/passportDataProvider.tsx b/app/src/providers/passportDataProvider.tsx index df0a1c11a..dffcab240 100644 --- a/app/src/providers/passportDataProvider.tsx +++ b/app/src/providers/passportDataProvider.tsx @@ -67,6 +67,12 @@ import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { createKeychainOptions } from '@/integrations/keychain'; import { unsafe_getPrivateKey, useAuth } from '@/providers/authProvider'; +import type { KeychainErrorType } from '@/utils/keychainErrors'; +import { + getKeychainErrorIdentity, + isKeychainCryptoError, + isUserCancellation, +} from '@/utils/keychainErrors'; let keychainCryptoFailureCallback: | ((errorType: 'user_cancelled' | 'crypto_failed') => void) @@ -78,29 +84,41 @@ export function setPassportKeychainErrorCallback( keychainCryptoFailureCallback = callback; } -function isUserCancellation(error: unknown): boolean { - const err = error as { code?: string; message?: string }; - // User cancelled biometric/PIN authentication - return Boolean( - err?.code === 'E_AUTHENTICATION_FAILED' || - err?.code === 'USER_CANCELED' || - err?.message?.includes('User canceled') || - err?.message?.includes('Authentication canceled') || - err?.message?.includes('cancelled by user'), - ); +function notifyKeychainFailure(type: KeychainErrorType) { + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback(type); + } } -function isKeychainCryptoError(error: unknown): boolean { - const err = error as { code?: string; name?: string; message?: string }; - // Only true crypto failures, not user cancellations - return Boolean( - (err?.code === 'E_CRYPTO_FAILED' || - err?.name === 'com.oblador.keychain.exceptions.CryptoFailedException' || - err?.message?.includes('CryptoFailedException') || - err?.message?.includes('Decryption failed') || - err?.message?.includes('Authentication tag verification failed')) && - !isUserCancellation(error), - ); +function handleKeychainReadError({ + contextLabel, + error, + throwOnUserCancel = false, +}: { + contextLabel: string; + error: unknown; + throwOnUserCancel?: boolean; +}) { + if (isUserCancellation(error)) { + console.log(`User cancelled authentication for ${contextLabel}`); + notifyKeychainFailure('user_cancelled'); + + if (throwOnUserCancel) { + throw error; + } + } + + if (isKeychainCryptoError(error)) { + const err = getKeychainErrorIdentity(error); + console.error(`Keychain crypto error loading ${contextLabel}:`, { + code: err?.code, + name: err?.name, + }); + + notifyKeychainFailure('crypto_failed'); + } + + console.log(`Error loading ${contextLabel}:`, error); } // Create safe wrapper functions to prevent undefined errors during early initialization @@ -482,25 +500,10 @@ export async function loadDocumentByIdDirectlyFromKeychain( return JSON.parse(documentCreds.password); } } catch (error) { - if (isUserCancellation(error)) { - console.log(`User cancelled authentication for document ${documentId}`); - if (keychainCryptoFailureCallback) { - keychainCryptoFailureCallback('user_cancelled'); - } - } - - if (isKeychainCryptoError(error)) { - const err = error as { code?: string; name?: string }; - console.error(`Keychain crypto error loading document ${documentId}:`, { - code: err?.code, - name: err?.name, - }); - - if (keychainCryptoFailureCallback) { - keychainCryptoFailureCallback('crypto_failed'); - } - } - console.log(`Error loading document ${documentId}:`, error); + handleKeychainReadError({ + contextLabel: `document ${documentId}`, + error, + }); } return null; } @@ -544,27 +547,11 @@ export async function loadDocumentCatalogDirectlyFromKeychain(): Promise { + it('identifies user cancellation errors', () => { + expect(isUserCancellation({ code: 'E_AUTHENTICATION_FAILED' })).toBe(true); + expect(isUserCancellation({ code: 'USER_CANCELED' })).toBe(true); + expect(isUserCancellation({ message: 'User canceled' })).toBe(true); + expect(isUserCancellation({ message: 'Authentication canceled' })).toBe( + true, + ); + expect(isUserCancellation({ message: 'cancelled by user' })).toBe(true); + }); + + it('does not classify non-cancellation errors as user cancellation', () => { + expect(isUserCancellation({ code: 'E_CRYPTO_FAILED' })).toBe(false); + expect(isUserCancellation({ message: 'Decryption failed' })).toBe(false); + expect(isUserCancellation({})).toBe(false); + }); + + it('identifies crypto failures and excludes user cancellations', () => { + expect(isKeychainCryptoError({ code: 'E_CRYPTO_FAILED' })).toBe(true); + expect( + isKeychainCryptoError({ + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }), + ).toBe(true); + expect( + isKeychainCryptoError({ + message: 'Authentication tag verification failed', + }), + ).toBe(true); + expect(isKeychainCryptoError({ message: 'Decryption failed' })).toBe(true); + expect( + isKeychainCryptoError({ + code: 'E_AUTHENTICATION_FAILED', + message: 'User canceled', + }), + ).toBe(false); + }); + + it('extracts keychain error identity safely', () => { + expect( + getKeychainErrorIdentity({ + code: 'E_CRYPTO_FAILED', + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }), + ).toEqual({ + code: 'E_CRYPTO_FAILED', + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }); + expect(getKeychainErrorIdentity({})).toEqual({ + code: undefined, + name: undefined, + }); + }); +}); From c25a35ef7abd56bb0ee64660df9d8db645634919 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:04:20 -0800 Subject: [PATCH 24/47] chore: bump mobile app version to 2.9.10 (#1574) 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 5477e32f9..ce8c2af95 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 199, - "lastDeployed": "2026-01-03T23:45:02.007Z" + "build": 200, + "lastDeployed": "2026-01-10T09:03:18.517Z" }, "android": { - "build": 130, - "lastDeployed": "2026-01-07T19:05:43Z" + "build": 131, + "lastDeployed": "2026-01-10T09:03:18.517Z" } } From 80d218178b52e4ee4a386da5660ee84ed4b7ae22 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sat, 10 Jan 2026 07:39:06 -0800 Subject: [PATCH 25/47] remove staleness redirect (#1575) --- .../verification/DocumentSelectorForProvingScreen.tsx | 4 ---- app/src/screens/verification/ProveScreen.tsx | 7 ------- 2 files changed, 11 deletions(-) diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx index 7fe03c08f..140bb3d79 100644 --- a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -44,7 +44,6 @@ import { truncateAddress, WalletAddressModal, } from '@/components/proof-request'; -import { useProofDisclosureStalenessCheck } from '@/hooks/useProofDisclosureStalenessCheck'; import { useSelfAppData } from '@/hooks/useSelfAppData'; import type { RootStackParamList } from '@/navigation'; import { usePassport } from '@/providers/passportDataProvider'; @@ -120,9 +119,6 @@ const DocumentSelectorForProvingScreen: React.FC = () => { const { logoSource, url, formattedUserId, disclosureItems } = useSelfAppData(selfApp); - // Check for stale data and navigate to Home if needed - useProofDisclosureStalenessCheck(selfApp, disclosureItems, navigation); - const [documentCatalog, setDocumentCatalog] = useState({ documents: [], }); diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 3f3df8e5d..21bfa6f32 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -38,7 +38,6 @@ import { truncateAddress, WalletAddressModal, } from '@/components/proof-request'; -import { useProofDisclosureStalenessCheck } from '@/hooks/useProofDisclosureStalenessCheck'; import { useSelfAppData } from '@/hooks/useSelfAppData'; import { buttonTap } from '@/integrations/haptics'; import type { RootStackParamList } from '@/navigation'; @@ -73,12 +72,6 @@ const ProveScreen: React.FC = () => { const { logoSource, url, formattedUserId, disclosureItems } = useSelfAppData(selectedApp); - // Check for stale data and navigate to Home if needed - useProofDisclosureStalenessCheck( - selectedApp, - disclosureItems, - navigation as NativeStackNavigationProp, - ); const selectedAppRef = useRef(null); const processedSessionsRef = useRef>(new Set()); From 621e71c2964913b68871d3e16a9e4c6bfb72fbbd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 08:08:50 -0800 Subject: [PATCH 26/47] chore: bump mobile app version to 2.9.10 (#1577) 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 ce8c2af95..f1d3a3f82 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 200, - "lastDeployed": "2026-01-10T09:03:18.517Z" + "build": 201, + "lastDeployed": "2026-01-10T16:06:58.864Z" }, "android": { - "build": 131, - "lastDeployed": "2026-01-10T09:03:18.517Z" + "build": 132, + "lastDeployed": "2026-01-10T16:06:58.864Z" } } From 7164099924f2e1af5a235dd518b80f4abc870d25 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sun, 11 Jan 2026 15:43:30 -0800 Subject: [PATCH 27/47] chore: address clunky Prove screen button feedback (#1578) * save button transition improvements * fix offset jump * add platform specific scroll offset padding * use dynamic sizing instead * fix scrolling logic --- app/src/screens/verification/ProveScreen.tsx | 86 ++++++++++++-------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 21bfa6f32..641d311d2 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -15,7 +15,7 @@ import type { NativeSyntheticEvent, ScrollView as ScrollViewType, } from 'react-native'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, useWindowDimensions } from 'react-native'; import { View, YStack } from 'tamagui'; import type { RouteProp } from '@react-navigation/native'; import { @@ -78,12 +78,12 @@ const ProveScreen: React.FC = () => { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0); const [scrollViewHeight, setScrollViewHeight] = useState(0); - const [hasLayoutMeasurements, setHasLayoutMeasurements] = useState(false); const [isDocumentExpired, setIsDocumentExpired] = useState(false); const [documentType, setDocumentType] = useState(''); const [walletModalOpen, setWalletModalOpen] = useState(false); const isDocumentExpiredRef = useRef(false); const scrollViewRef = useRef(null); + const hasInitializedScrollStateRef = useRef(false); const isContentShorterThanScrollView = useMemo( () => scrollViewContentHeight <= scrollViewHeight + 50, @@ -91,13 +91,27 @@ const ProveScreen: React.FC = () => { ); const isScrollable = useMemo( - () => !isContentShorterThanScrollView && hasLayoutMeasurements, - [isContentShorterThanScrollView, hasLayoutMeasurements], + () => !isContentShorterThanScrollView, + [isContentShorterThanScrollView], ); const provingStore = useProvingStore(); const currentState = useProvingStore(state => state.currentState); const isReadyToProve = currentState === 'ready_to_prove'; + // Use window dimensions for dynamic scroll offset padding + // This scales with viewport height rather than using hardcoded platform values + const { height: windowHeight } = useWindowDimensions(); + + const initialScrollOffset = useMemo(() => { + if (route.params?.scrollOffset === undefined) { + return undefined; + } + // Use ~1.5% of window height as padding to account for minor layout differences + // This scales appropriately across different device sizes + const padding = windowHeight * 0.01; + return route.params.scrollOffset + padding; + }, [route.params?.scrollOffset, windowHeight]); + const { addProofHistory } = useProofHistoryStore(); const { loadDocumentCatalog } = usePassport(); @@ -126,21 +140,40 @@ const ProveScreen: React.FC = () => { }, [addProofHistory, loadDocumentCatalog, provingStore.uuid, selectedApp]); useEffect(() => { - // Only update hasScrolledToBottom once we have real layout measurements - if (hasLayoutMeasurements) { - if (isContentShorterThanScrollView) { - setHasScrolledToBottom(true); - } else { - setHasScrolledToBottom(false); - } + // Wait for actual measurements before determining initial scroll state + // Both start at 0, causing false-positive on first render + const hasMeasurements = scrollViewContentHeight > 0 && scrollViewHeight > 0; + + if (!hasMeasurements || hasInitializedScrollStateRef.current) { + return; } - }, [isContentShorterThanScrollView, hasLayoutMeasurements]); + + // Only auto-enable if content is short enough that no scrolling is needed + if (isContentShorterThanScrollView) { + setHasScrolledToBottom(true); + } + // If content is long, leave hasScrolledToBottom as false (require scroll) + // Don't explicitly set to false to avoid resetting user's scroll progress + + // Mark as initialized so we don't override user's scroll state later + hasInitializedScrollStateRef.current = true; + }, [ + isContentShorterThanScrollView, + scrollViewContentHeight, + scrollViewHeight, + ]); useEffect(() => { if (!isFocused || !selectedApp) { return; } + // Reset scroll state tracking for new session + if (selectedAppRef.current?.sessionId !== selectedApp.sessionId) { + hasInitializedScrollStateRef.current = false; + setHasScrolledToBottom(false); + } + setDefaultDocumentTypeIfNeeded(); const checkExpirationAndInit = async () => { @@ -282,31 +315,14 @@ const ProveScreen: React.FC = () => { const handleContentSizeChange = useCallback( (contentWidth: number, contentHeight: number) => { setScrollViewContentHeight(contentHeight); - // If we now have both measurements and content fits on screen, enable button immediately - if (contentHeight > 0 && scrollViewHeight > 0) { - setHasLayoutMeasurements(true); - if (contentHeight <= scrollViewHeight + 50) { - setHasScrolledToBottom(true); - } - } }, - [scrollViewHeight], + [], ); - const handleScrollViewLayout = useCallback( - (event: LayoutChangeEvent) => { - const layoutHeight = event.nativeEvent.layout.height; - setScrollViewHeight(layoutHeight); - // If we now have both measurements and content fits on screen, enable button immediately - if (layoutHeight > 0 && scrollViewContentHeight > 0) { - setHasLayoutMeasurements(true); - if (scrollViewContentHeight <= layoutHeight + 50) { - setHasScrolledToBottom(true); - } - } - }, - [scrollViewContentHeight], - ); + const handleScrollViewLayout = useCallback((event: LayoutChangeEvent) => { + const layoutHeight = event.nativeEvent.layout.height; + setScrollViewHeight(layoutHeight); + }, []); return ( @@ -333,7 +349,7 @@ const ProveScreen: React.FC = () => { scrollViewRef={scrollViewRef} onContentSizeChange={handleContentSizeChange} onLayout={handleScrollViewLayout} - initialScrollOffset={route.params?.scrollOffset} + initialScrollOffset={initialScrollOffset} testID="prove-screen-card" > {/* Disclosure Items */} From e7ed53aff5e026694d361125a9384e00d8967447 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 12 Jan 2026 07:39:14 -0800 Subject: [PATCH 28/47] rename Approve to Select (#1581) --- app/src/components/documents/IDSelectorSheet.tsx | 2 +- app/src/components/proof-request/BottomActionBar.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/components/documents/IDSelectorSheet.tsx b/app/src/components/documents/IDSelectorSheet.tsx index 152f811e9..9f50de8b1 100644 --- a/app/src/components/documents/IDSelectorSheet.tsx +++ b/app/src/components/documents/IDSelectorSheet.tsx @@ -163,7 +163,7 @@ export const IDSelectorSheet: React.FC = ({ fontWeight="500" color={white} > - Approve + Select diff --git a/app/src/components/proof-request/BottomActionBar.tsx b/app/src/components/proof-request/BottomActionBar.tsx index 9c5f50fc3..3f0e22919 100644 --- a/app/src/components/proof-request/BottomActionBar.tsx +++ b/app/src/components/proof-request/BottomActionBar.tsx @@ -104,7 +104,7 @@ export const BottomActionBar: React.FC = ({ - {/* Approve Button */} + {/* Select Button */} = ({ color={proofRequestColors.white} textAlign="center" > - Approve + Select )} From 551bfd5787f3b7716db52f7d7757cb6a7b7a6a7c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 07:40:06 -0800 Subject: [PATCH 29/47] chore: bump mobile app version to 2.9.10 (#1580) 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 f1d3a3f82..305888e1b 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 201, - "lastDeployed": "2026-01-10T16:06:58.864Z" + "build": 202, + "lastDeployed": "2026-01-12T14:47:51.102Z" }, "android": { - "build": 132, - "lastDeployed": "2026-01-10T16:06:58.864Z" + "build": 133, + "lastDeployed": "2026-01-12T14:47:51.102Z" } } From d132633c203563b47beeba79f3a43624423e7515 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 08:23:27 -0800 Subject: [PATCH 30/47] chore: bump mobile app version to 2.9.10 (#1583) 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 305888e1b..1b9ef12ac 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 202, - "lastDeployed": "2026-01-12T14:47:51.102Z" + "build": 203, + "lastDeployed": "2026-01-12T16:10:12.854Z" }, "android": { - "build": 133, - "lastDeployed": "2026-01-12T14:47:51.102Z" + "build": 134, + "lastDeployed": "2026-01-12T16:10:12.854Z" } } From 9f5171f21e7b1fd66fba1cd4de65bd6d3ab27543 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 12 Jan 2026 11:29:57 -0800 Subject: [PATCH 31/47] Add proving machine refactor guardrail tests (#1584) * Add proving machine refactor tests * Clarify refactor guardrail test intent * Add Socket.IO status handler wiring tests for proving store (#1586) * Add status handler listener tests * Fix failure status test payload * Add hello ack uuid test (#1588) * formatting * format, agent feedback * delete mock mock tests --- .../proving/internal/payloadGenerator.test.ts | 252 ++++++++++++++++++ .../proving/internal/statusListener.test.ts | 172 ++++++++++++ .../internal/websocketHandlers.test.ts | 227 ++++++++++++++++ .../internal/websocketUrlResolver.test.ts | 205 ++++++++++++++ 4 files changed, 856 insertions(+) create mode 100644 packages/mobile-sdk-alpha/tests/proving/internal/payloadGenerator.test.ts create mode 100644 packages/mobile-sdk-alpha/tests/proving/internal/statusListener.test.ts create mode 100644 packages/mobile-sdk-alpha/tests/proving/internal/websocketHandlers.test.ts create mode 100644 packages/mobile-sdk-alpha/tests/proving/internal/websocketUrlResolver.test.ts diff --git a/packages/mobile-sdk-alpha/tests/proving/internal/payloadGenerator.test.ts b/packages/mobile-sdk-alpha/tests/proving/internal/payloadGenerator.test.ts new file mode 100644 index 000000000..e12b5384c --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/internal/payloadGenerator.test.ts @@ -0,0 +1,252 @@ +// 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 { SelfClient } from '../../../src'; +import { useProvingStore } from '../../../src/proving/provingMachine'; +import { useProtocolStore } from '../../../src/stores/protocolStore'; +import { useSelfAppStore } from '../../../src/stores/selfAppStore'; +import { actorMock } from '../actorMock'; + +vitest.mock('xstate', () => { + return { + createActor: vitest.fn(() => actorMock), + createMachine: vitest.fn(), + assign: vitest.fn(), + send: vitest.fn(), + spawn: vitest.fn(), + interpret: vitest.fn(), + fromPromise: vitest.fn(), + fromObservable: vitest.fn(), + fromEventObservable: vitest.fn(), + fromCallback: vitest.fn(), + fromTransition: vitest.fn(), + fromReducer: vitest.fn(), + fromRef: vitest.fn(), + }; +}); + +vitest.mock('@selfxyz/common/utils/proving', async () => { + const actual = await vitest.importActual( + '@selfxyz/common/utils/proving', + ); + return { + ...actual, + getPayload: vitest.fn(() => ({ payload: true })), + encryptAES256GCM: vitest.fn(() => ({ + nonce: [1], + cipher_text: [2], + auth_tag: [3], + })), + }; +}); + +vitest.mock('@selfxyz/common/utils/circuits/registerInputs', async () => { + const actual = (await vitest.importActual('@selfxyz/common/utils/circuits/registerInputs')) as any; + return { + ...actual, + generateTEEInputsRegister: vitest.fn(async () => ({ + inputs: { reg: true }, + circuitName: 'register_circuit', + endpointType: 'celo', + endpoint: 'https://register', + })), + generateTEEInputsDSC: vitest.fn(() => ({ + inputs: { dsc: true }, + circuitName: 'dsc_circuit', + endpointType: 'celo', + endpoint: 'https://dsc', + })), + generateTEEInputsDiscloseStateless: vitest.fn(() => ({ + inputs: { disclose: true }, + circuitName: 'disclose_circuit', + endpointType: 'https', + endpoint: 'https://disclose', + })), + }; +}); + +describe('payload generator (refactor guardrail via _generatePayload)', () => { + const selfClient: SelfClient = { + trackEvent: vitest.fn(), + emit: vitest.fn(), + logProofEvent: vitest.fn(), + getPrivateKey: vitest.fn(), + getSelfAppState: () => useSelfAppStore.getState(), + getProvingState: () => useProvingStore.getState(), + getProtocolState: () => useProtocolStore.getState(), + } as unknown as SelfClient; + + beforeEach(() => { + vitest.clearAllMocks(); + useSelfAppStore.setState({ + selfApp: { + chainID: 42220, + userId: '12345678-1234-1234-1234-123456789abc', + userDefinedData: '0x0', + selfDefinedData: '', + endpointType: 'https', + endpoint: 'https://endpoint', + scope: 'scope', + sessionId: '', + appName: '', + logoBase64: '', + header: '', + userIdType: 'uuid', + devMode: false, + disclosures: {}, + version: 1, + deeplinkCallback: '', + }, + }); + }); + + it('builds a submit request payload with the encrypted payload', async () => { + useProvingStore.setState({ + circuitType: 'register', + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + const payload = await useProvingStore.getState()._generatePayload(selfClient); + + expect(payload).toEqual({ + jsonrpc: '2.0', + method: 'openpassport_submit_request', + id: 2, + params: { + uuid: 'uuid-123', + nonce: [1], + cipher_text: [2], + auth_tag: [3], + }, + }); + }); + + it('throws when dsc is requested for aadhaar documents', async () => { + useProvingStore.setState({ + circuitType: 'dsc', + passportData: { documentCategory: 'aadhaar', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await expect(useProvingStore.getState()._generatePayload(selfClient)).rejects.toThrow( + 'DSC circuit type is not supported for Aadhaar documents', + ); + }); + + it('throws when disclose circuit is requested without a SelfApp', async () => { + useSelfAppStore.setState({ selfApp: null }); + useProvingStore.setState({ + circuitType: 'disclose', + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await expect(useProvingStore.getState()._generatePayload(selfClient)).rejects.toThrow( + 'SelfApp context not initialized', + ); + }); + + it('throws on invalid circuit types', async () => { + useProvingStore.setState({ + circuitType: 'invalid' as any, + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await expect(useProvingStore.getState()._generatePayload(selfClient)).rejects.toThrow( + 'Invalid circuit type:invalid', + ); + }); + + it('uses register_id for id cards', async () => { + const { getPayload } = await import('@selfxyz/common/utils/proving'); + + useProvingStore.setState({ + circuitType: 'register', + passportData: { documentCategory: 'id_card', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await useProvingStore.getState()._generatePayload(selfClient); + + expect(getPayload).toHaveBeenCalledWith( + { reg: true }, + 'register_id', + 'register_circuit', + 'celo', + 'https://register', + 1, + expect.any(String), + '', + ); + }); + + it('keeps dsc circuit type for passport documents', async () => { + const { getPayload } = await import('@selfxyz/common/utils/proving'); + + useProvingStore.setState({ + circuitType: 'dsc', + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await useProvingStore.getState()._generatePayload(selfClient); + + expect(getPayload).toHaveBeenCalledWith( + { dsc: true }, + 'dsc', + 'dsc_circuit', + 'celo', + 'https://dsc', + 1, + expect.any(String), + '', + ); + }); + + it('always uses disclose for disclosure flows', async () => { + const { getPayload } = await import('@selfxyz/common/utils/proving'); + + useProvingStore.setState({ + circuitType: 'disclose', + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await useProvingStore.getState()._generatePayload(selfClient); + + expect(getPayload).toHaveBeenCalledWith( + { disclose: true }, + 'disclose', + 'disclose_circuit', + 'https', + 'https://disclose', + 1, + expect.any(String), + '', + ); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/proving/internal/statusListener.test.ts b/packages/mobile-sdk-alpha/tests/proving/internal/statusListener.test.ts new file mode 100644 index 000000000..728a68a0c --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/internal/statusListener.test.ts @@ -0,0 +1,172 @@ +// 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 { EventEmitter } from 'events'; +import type { Socket } from 'socket.io-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useProvingStore } from '../../../src/proving/provingMachine'; +import { actorMock } from '../actorMock'; + +vi.mock('socket.io-client'); +vi.mock('../../../src/constants/analytics', () => ({ + ProofEvents: { + SOCKETIO_CONN_STARTED: 'SOCKETIO_CONN_STARTED', + SOCKETIO_SUBSCRIBED: 'SOCKETIO_SUBSCRIBED', + SOCKETIO_STATUS_RECEIVED: 'SOCKETIO_STATUS_RECEIVED', + SOCKETIO_PROOF_FAILURE: 'SOCKETIO_PROOF_FAILURE', + SOCKETIO_PROOF_SUCCESS: 'SOCKETIO_PROOF_SUCCESS', + REGISTER_COMPLETED: 'REGISTER_COMPLETED', + }, + PassportEvents: {}, +})); + +vi.mock('../../../src/proving/internal/logging', () => ({ + logProofEvent: vi.fn(), + createProofContext: vi.fn(() => ({})), +})); +vi.mock('@selfxyz/common/utils/proving', () => ({ + getWSDbRelayerUrl: vi.fn(() => 'ws://test-url'), + getPayload: vi.fn(), + encryptAES256GCM: vi.fn(), + clientKey: {}, + clientPublicKeyHex: 'test-key', + ec: {}, +})); + +vi.mock('../../../src/documents/utils', () => ({ + loadSelectedDocument: vi.fn(() => + Promise.resolve({ + data: { mockData: true }, + version: '1.0.0', + }), + ), + hasAnyValidRegisteredDocument: vi.fn(() => Promise.resolve(true)), + clearPassportData: vi.fn(), + markCurrentDocumentAsRegistered: vi.fn(), + reStorePassportDataWithRightCSCA: vi.fn(), +})); + +vi.mock('../../../src/types/events', () => ({ + SdkEvents: { + PASSPORT_DATA_NOT_FOUND: 'PASSPORT_DATA_NOT_FOUND', + }, +})); + +vi.mock('@selfxyz/common/utils', () => ({ + getCircuitNameFromPassportData: vi.fn(() => 'register'), + getSolidityPackedUserContextData: vi.fn(() => '0x123'), +})); + +vi.mock('@selfxyz/common/utils/attest', () => ({ + getPublicKey: vi.fn(), + verifyAttestation: vi.fn(), +})); + +vi.mock('@selfxyz/common/utils/circuits/registerInputs', () => ({ + generateTEEInputsDSC: vi.fn(), + generateTEEInputsRegister: vi.fn(), +})); + +vi.mock('@selfxyz/common/utils/passports/validate', () => ({ + checkDocumentSupported: vi.fn(() => Promise.resolve(true)), + checkIfPassportDscIsInTree: vi.fn(() => Promise.resolve(true)), + isDocumentNullified: vi.fn(() => Promise.resolve(false)), + isUserRegistered: vi.fn(() => Promise.resolve(false)), + isUserRegisteredWithAlternativeCSCA: vi.fn(() => Promise.resolve(false)), +})); + +vi.mock('xstate', () => ({ + createActor: vi.fn(() => actorMock), + createMachine: vi.fn(() => ({})), +})); + +describe('Socket.IO status handler wiring', () => { + const mockSelfClient = { + trackEvent: vi.fn(), + emit: vi.fn(), + getPrivateKey: vi.fn(() => Promise.resolve('mock-private-key')), + logProofEvent: vi.fn(), + getSelfAppState: () => ({ + selfApp: {}, + }), + getProtocolState: () => ({ + isUserLoggedIn: true, + }), + getProvingState: () => useProvingStore.getState(), + } as any; + + let mockSocket: EventEmitter & Partial; + let socketIoMock: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + useProvingStore.setState({ + socketConnection: null, + error_code: null, + reason: null, + circuitType: 'register', + } as any); + + mockSocket = new EventEmitter() as EventEmitter & Partial; + vi.spyOn(mockSocket as any, 'emit'); + mockSocket.disconnect = vi.fn(); + + const socketIo = await import('socket.io-client'); + socketIoMock = vi.mocked(socketIo.default || socketIo); + socketIoMock.mockReturnValue(mockSocket); + + const store = useProvingStore.getState(); + await store.init(mockSelfClient, 'register', true); + actorMock.send.mockClear(); + }); + + it('applies success updates and emits PROVE_SUCCESS', async () => { + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + await new Promise(resolve => setImmediate(resolve)); + + (mockSocket as any).emit('status', { status: 4 }); + + const finalState = useProvingStore.getState(); + expect(finalState.socketConnection).toBe(null); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_SUCCESS' }); + }); + + it('applies failure updates and emits PROVE_FAILURE', async () => { + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + await new Promise(resolve => setImmediate(resolve)); + + (mockSocket as any).emit('status', { + status: 5, + error_code: 'E001', + reason: 'TEE failed', + }); + + const finalState = useProvingStore.getState(); + expect(finalState.error_code).toBe('E001'); + expect(finalState.reason).toBe('TEE failed'); + expect(finalState.socketConnection).toBe(null); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_FAILURE' }); + }); + + it('emits PROVE_ERROR without updating state for retryable errors', async () => { + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + await new Promise(resolve => setImmediate(resolve)); + + (mockSocket as any).emit('status', '{"invalid": json}'); + + const finalState = useProvingStore.getState(); + expect(finalState.socketConnection).toBe(mockSocket); + expect(finalState.error_code).toBe(null); + expect(finalState.reason).toBe(null); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_ERROR' }); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/proving/internal/websocketHandlers.test.ts b/packages/mobile-sdk-alpha/tests/proving/internal/websocketHandlers.test.ts new file mode 100644 index 000000000..42b4f7c43 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/internal/websocketHandlers.test.ts @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import type { SelfClient } from '../../../src'; +import * as documentUtils from '../../../src/documents/utils'; +import { useProvingStore } from '../../../src/proving/provingMachine'; +import { useProtocolStore } from '../../../src/stores/protocolStore'; +import { useSelfAppStore } from '../../../src/stores/selfAppStore'; +import { actorMock } from '../actorMock'; + +vitest.mock('uuid', () => ({ + v4: vitest.fn(() => 'uuid-123'), +})); + +vitest.mock('xstate', () => { + return { + createActor: vitest.fn(() => actorMock), + createMachine: vitest.fn(), + assign: vitest.fn(), + send: vitest.fn(), + spawn: vitest.fn(), + interpret: vitest.fn(), + fromPromise: vitest.fn(), + fromObservable: vitest.fn(), + fromEventObservable: vitest.fn(), + fromCallback: vitest.fn(), + fromTransition: vitest.fn(), + fromReducer: vitest.fn(), + fromRef: vitest.fn(), + }; +}); + +vitest.mock('@selfxyz/common/utils/attest', () => { + return { + validatePKIToken: vitest.fn(() => ({ + userPubkey: Buffer.from('abcd', 'hex'), + serverPubkey: 'server-key', + imageHash: 'hash', + verified: true, + })), + checkPCR0Mapping: vitest.fn(async () => true), + }; +}); + +vitest.mock('@selfxyz/common/utils/proving', async () => { + const actual = await vitest.importActual( + '@selfxyz/common/utils/proving', + ); + return { + ...actual, + clientPublicKeyHex: 'abcd', + clientKey: { + derive: vitest.fn(() => ({ + toArray: () => Array(32).fill(7), + })), + }, + ec: { + keyFromPublic: vitest.fn(() => ({ + getPublic: vitest.fn(() => 'server-public'), + })), + }, + }; +}); + +describe('websocket handlers (refactor guardrail via proving store)', () => { + const selfClient: SelfClient = { + trackEvent: vitest.fn(), + emit: vitest.fn(), + logProofEvent: vitest.fn(), + getPrivateKey: vitest.fn().mockResolvedValue('secret'), + getSelfAppState: () => useSelfAppStore.getState(), + getProvingState: () => useProvingStore.getState(), + getProtocolState: () => useProtocolStore.getState(), + } as unknown as SelfClient; + let loadSelectedDocumentSpy: any; + + beforeEach(() => { + vitest.clearAllMocks(); + (globalThis as { __DEV__?: boolean }).__DEV__ = true; + useSelfAppStore.setState({ selfApp: null, sessionId: null, socket: null }); + if (!loadSelectedDocumentSpy) { + loadSelectedDocumentSpy = vitest.spyOn(documentUtils, 'loadSelectedDocument'); + } + loadSelectedDocumentSpy.mockResolvedValue({ + data: { + documentCategory: 'passport', + mock: false, + dsc_parsed: { authorityKeyIdentifier: 'aki' }, + } as any, + } as any); + }); + + it('does nothing when actor is missing or wsConnection is null', () => { + useProvingStore.setState({ wsConnection: null } as any); + + useProvingStore.getState()._handleWsOpen(selfClient); + + expect(actorMock.send).not.toHaveBeenCalled(); + }); + + it('does nothing when wsConnection is null even if actor exists', async () => { + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + useProvingStore.setState({ wsConnection: null } as any); + + useProvingStore.getState()._handleWsOpen(selfClient); + + expect(actorMock.send).not.toHaveBeenCalled(); + }); + + it('sends hello message and stores uuid on open', async () => { + const wsConnection = { + send: vitest.fn(), + } as unknown as WebSocket; + + await useProvingStore.getState().init(selfClient, 'register'); + useProvingStore.setState({ wsConnection }); + + useProvingStore.getState()._handleWsOpen(selfClient); + + expect(useProvingStore.getState().uuid).toBe('uuid-123'); + const [sentMessage] = (wsConnection.send as unknown as { mock: { calls: string[][] } }).mock.calls[0]; + const parsedMessage = JSON.parse(sentMessage); + expect(parsedMessage).toMatchObject({ + jsonrpc: '2.0', + method: 'openpassport_hello', + id: 1, + params: { + uuid: 'uuid-123', + user_pubkey: expect.any(Array), + }, + }); + }); + + it('handles attestation messages by deriving shared key and emitting CONNECT_SUCCESS', async () => { + const { clientPublicKeyHex } = await import('@selfxyz/common/utils/proving'); + const { validatePKIToken } = await import('@selfxyz/common/utils/attest'); + + await useProvingStore.getState().init(selfClient, 'register'); + useProvingStore.setState({ currentState: 'init_tee_connexion' } as any); + + const event = { data: JSON.stringify({ result: { attestation: [1, 2, 3] } }) } as MessageEvent; + + await useProvingStore.getState()._handleWebSocketMessage(event, selfClient); + + expect(clientPublicKeyHex).toBe('abcd'); + expect(validatePKIToken).toHaveBeenCalled(); + expect(useProvingStore.getState().sharedKey).toEqual(Buffer.from(Array(32).fill(7))); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'CONNECT_SUCCESS' }); + }); + + it('starts socket listener on hello ack', async () => { + await useProvingStore.getState().init(selfClient, 'register'); + const startListener = vitest.fn(); + useProvingStore.setState({ + endpointType: 'https', + uuid: 'uuid-123', + _startSocketIOStatusListener: startListener, + } as any); + + const event = new MessageEvent('message', { + data: JSON.stringify({ id: 2, result: 'status-uuid' }), + }); + + await useProvingStore.getState()._handleWebSocketMessage(event, selfClient); + + expect(startListener).toHaveBeenCalledWith('status-uuid', 'https', selfClient); + }); + + it('uses hello ack uuid when it differs from stored uuid', async () => { + await useProvingStore.getState().init(selfClient, 'register'); + const startListener = vitest.fn(); + useProvingStore.setState({ + endpointType: 'https', + uuid: 'uuid-123', + _startSocketIOStatusListener: startListener, + } as any); + + const event = new MessageEvent('message', { + data: JSON.stringify({ id: 2, result: 'uuid-456' }), + }); + + await useProvingStore.getState()._handleWebSocketMessage(event, selfClient); + + expect(startListener).toHaveBeenCalledWith('uuid-456', 'https', selfClient); + }); + + it('emits PROVE_ERROR on websocket error payloads', async () => { + await useProvingStore.getState().init(selfClient, 'register'); + + const event = new MessageEvent('message', { + data: JSON.stringify({ error: 'bad' }), + }); + + await useProvingStore.getState()._handleWebSocketMessage(event, selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_ERROR' }); + }); + + it.each([ + { state: 'init_tee_connexion', expected: 'PROVE_ERROR' }, + { state: 'proving', expected: 'PROVE_ERROR' }, + { state: 'listening_for_status', expected: 'PROVE_ERROR' }, + ])('emits $expected when websocket closes during $state', async ({ state, expected }) => { + await useProvingStore.getState().init(selfClient, 'register'); + useProvingStore.setState({ currentState: state } as any); + + const event = { code: 1000, reason: 'closed' } as CloseEvent; + + useProvingStore.getState()._handleWsClose(event, selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: expected }); + }); + + it.each([ + { state: 'init_tee_connexion', expected: 'PROVE_ERROR' }, + { state: 'proving', expected: 'PROVE_ERROR' }, + ])('emits $expected when websocket errors during $state', async ({ state, expected }) => { + await useProvingStore.getState().init(selfClient, 'register'); + useProvingStore.setState({ currentState: state } as any); + + useProvingStore.getState()._handleWsError(new Event('error'), selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: expected }); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/proving/internal/websocketUrlResolver.test.ts b/packages/mobile-sdk-alpha/tests/proving/internal/websocketUrlResolver.test.ts new file mode 100644 index 000000000..5af469c6f --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/internal/websocketUrlResolver.test.ts @@ -0,0 +1,205 @@ +// 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 { SelfClient } from '../../../src'; +import * as documentUtils from '../../../src/documents/utils'; +import { useProvingStore } from '../../../src/proving/provingMachine'; +import { useProtocolStore } from '../../../src/stores/protocolStore'; +import { actorMock, emitState } from '../actorMock'; + +vitest.mock('xstate', () => { + return { + createActor: vitest.fn(() => actorMock), + createMachine: vitest.fn(), + assign: vitest.fn(), + send: vitest.fn(), + spawn: vitest.fn(), + interpret: vitest.fn(), + fromPromise: vitest.fn(), + fromObservable: vitest.fn(), + fromEventObservable: vitest.fn(), + fromCallback: vitest.fn(), + fromTransition: vitest.fn(), + fromReducer: vitest.fn(), + fromRef: vitest.fn(), + }; +}); + +vitest.mock('@selfxyz/common/utils', async () => { + const actual = await vitest.importActual('@selfxyz/common/utils'); + return { + ...actual, + getCircuitNameFromPassportData: vitest.fn(() => 'mock-circuit'), + }; +}); + +describe('websocket URL resolution (refactor guardrail via initTeeConnection)', () => { + const wsSend = vitest.fn(); + const wsAddEventListener = vitest.fn(); + const wsMock = vitest.fn(() => ({ + addEventListener: wsAddEventListener, + send: wsSend, + })); + let loadSelectedDocumentSpy: any; + + const makeSelfClient = (): SelfClient => + ({ + getPrivateKey: vitest.fn().mockResolvedValue('secret'), + trackEvent: vitest.fn(), + logProofEvent: vitest.fn(), + getProvingState: () => useProvingStore.getState(), + getProtocolState: () => useProtocolStore.getState(), + getSelfAppState: () => ({ selfApp: null }), + }) as unknown as SelfClient; + + const setCircuitsMapping = (documentCategory: 'passport' | 'id_card' | 'aadhaar', mapping: any) => { + useProtocolStore.setState(state => ({ + [documentCategory]: { + ...state[documentCategory], + circuits_dns_mapping: mapping, + }, + })); + }; + + beforeEach(() => { + vitest.restoreAllMocks(); + vitest.clearAllMocks(); + global.WebSocket = wsMock as unknown as typeof WebSocket; + loadSelectedDocumentSpy = vitest.spyOn(documentUtils, 'loadSelectedDocument'); + }); + + it.each([ + { + label: 'disclose passport -> DISCLOSE', + circuitType: 'disclose' as const, + documentCategory: 'passport' as const, + circuitName: 'disclose', + mappingKey: 'DISCLOSE', + }, + { + label: 'disclose id_card -> DISCLOSE_ID', + circuitType: 'disclose' as const, + documentCategory: 'id_card' as const, + circuitName: 'disclose', + mappingKey: 'DISCLOSE_ID', + }, + { + label: 'disclose aadhaar -> DISCLOSE_AADHAAR', + circuitType: 'disclose' as const, + documentCategory: 'aadhaar' as const, + circuitName: 'disclose_aadhaar', + mappingKey: 'DISCLOSE_AADHAAR', + }, + { + label: 'register passport -> REGISTER', + circuitType: 'register' as const, + documentCategory: 'passport' as const, + circuitName: 'mock-circuit', + mappingKey: 'REGISTER', + }, + { + label: 'register id_card -> REGISTER_ID', + circuitType: 'register' as const, + documentCategory: 'id_card' as const, + circuitName: 'mock-circuit', + mappingKey: 'REGISTER_ID', + }, + { + label: 'register aadhaar -> REGISTER_AADHAAR', + circuitType: 'register' as const, + documentCategory: 'aadhaar' as const, + circuitName: 'mock-circuit', + mappingKey: 'REGISTER_AADHAAR', + }, + { + label: 'dsc passport -> DSC', + circuitType: 'dsc' as const, + documentCategory: 'passport' as const, + circuitName: 'mock-circuit', + mappingKey: 'DSC', + }, + { + label: 'dsc id_card -> DSC_ID', + circuitType: 'dsc' as const, + documentCategory: 'id_card' as const, + circuitName: 'mock-circuit', + mappingKey: 'DSC_ID', + }, + ])('$label resolves expected WebSocket URL', async ({ circuitType, documentCategory, circuitName, mappingKey }) => { + const selfClient = makeSelfClient(); + const wsUrl = `wss://example/${mappingKey}`; + + loadSelectedDocumentSpy.mockResolvedValue({ + data: { + documentCategory, + mock: false, + dsc_parsed: { authorityKeyIdentifier: 'aki' }, + } as any, + } as any); + + setCircuitsMapping(documentCategory, { + [mappingKey]: { + [circuitName]: wsUrl, + }, + }); + + await useProvingStore.getState().init(selfClient, circuitType); + + const initPromise = useProvingStore.getState().initTeeConnection(selfClient); + emitState('ready_to_prove'); + await initPromise; + + expect(wsMock).toHaveBeenCalledWith(wsUrl); + }); + + it('throws when mapping is missing for the circuit', async () => { + const selfClient = makeSelfClient(); + + loadSelectedDocumentSpy.mockResolvedValue({ + data: { + documentCategory: 'passport', + mock: false, + dsc_parsed: { authorityKeyIdentifier: 'aki' }, + } as any, + } as any); + + setCircuitsMapping('passport', { + REGISTER: { + other: 'wss://missing', + }, + }); + + await useProvingStore.getState().init(selfClient, 'register'); + + await expect(useProvingStore.getState().initTeeConnection(selfClient)).rejects.toThrow( + 'No WebSocket URL available for TEE connection', + ); + }); + + it('throws for unsupported document categories', async () => { + const selfClient = makeSelfClient(); + const invalidCategory = 'driver_license'; + + loadSelectedDocumentSpy.mockResolvedValue({ + data: { + documentCategory: invalidCategory, + mock: false, + dsc_parsed: { authorityKeyIdentifier: 'aki' }, + } as any, + } as any); + + useProtocolStore.setState(state => ({ + ...(state as any), + [invalidCategory]: { + circuits_dns_mapping: {}, + }, + })); + + await useProvingStore.getState().init(selfClient, 'disclose'); + + await expect(useProvingStore.getState().initTeeConnection(selfClient)).rejects.toThrow( + 'Unsupported document category for disclose: driver_license', + ); + }); +}); From 8eff3d030d1640d719bac3edf3e8fb39468ebe28 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 12 Jan 2026 12:42:34 -0800 Subject: [PATCH 32/47] Limit keychain modal to Android (#1591) --- app/src/providers/selfClientProvider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index bff97ccff..03aa25f0a 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -349,6 +349,7 @@ export function enableKeychainErrorModal() { export function showKeychainErrorModal( errorType: 'user_cancelled' | 'crypto_failed', ) { + if (Platform.OS !== 'android') return; if (!navigationRef.isReady()) return; const errorContent = { From 9d83672920de54dda143ce76c8cdf82140ae458f Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 12 Jan 2026 13:17:53 -0800 Subject: [PATCH 33/47] Adjust cloud backup visibility (#1593) --- .../account/settings/SettingsScreen.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx index 535d52b56..f9ea00ce2 100644 --- a/app/src/screens/account/settings/SettingsScreen.tsx +++ b/app/src/screens/account/settings/SettingsScreen.tsx @@ -107,10 +107,10 @@ const DEBUG_MENU: [React.FC, string, RouteOption][] = [ ]; const DOCUMENT_DEPENDENT_ROUTES: RouteOption[] = [ - 'CloudBackupSettings', 'DocumentDataInfo', 'ShowRecoveryPhrase', ]; +const CLOUD_BACKUP_ROUTE: RouteOption = 'CloudBackupSettings'; const social = [ [X, xUrl], @@ -193,10 +193,20 @@ const SettingsScreen: React.FC = () => { return baseRoutes; } + const shouldHideCloudBackup = Platform.OS === 'android'; + // Only filter out document-related routes if we've confirmed user has no real documents - return baseRoutes.filter( - ([, , route]) => !DOCUMENT_DEPENDENT_ROUTES.includes(route), - ); + return baseRoutes.filter(([, , route]) => { + if (DOCUMENT_DEPENDENT_ROUTES.includes(route)) { + return false; + } + + if (shouldHideCloudBackup && route === CLOUD_BACKUP_ROUTE) { + return false; + } + + return true; + }); }, [hasRealDocument, isDevMode]); const devModeTap = Gesture.Tap() From ba8aace38a23a0d74df104bc227f3e3d58e414e5 Mon Sep 17 00:00:00 2001 From: Artur Date: Mon, 12 Jan 2026 13:23:40 -0800 Subject: [PATCH 34/47] Feat add issue templates (#1557) * Add issue templates for bug reports and feature requests * Remove test script from PR (not needed for issue templates) --- .github/ISSUE_TEMPLATE/bug_report.md | 37 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 29 ++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..55e4e90fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior +title: '[Bug] ' +labels: bug +assignees: '' +--- + +> **⚠️ Security Issues**: If you've discovered a security vulnerability, **do not** open a public issue. Please report it responsibly by emailing **team@self.xyz** instead. + +## Description + +_A clear and concise description of what the bug is._ + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +_What you expected to happen._ + +## Actual Behavior + +_What actually happened._ + +## Environment (optional) + +- Workspace: _e.g., app, circuits, contracts, sdk/core, etc._ +- Platform: _e.g., iOS, Android, Web_ +- Version: _if applicable_ + +## Additional Context + +_Any other context, logs, or screenshots that might help._ diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..d035793d5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,29 @@ +--- +name: Feature Request / Contribution +about: Suggest a new feature or propose a contribution +title: '[Feature] ' +labels: enhancement +assignees: '' +--- + +> **💡 For Complex Features**: If your contribution targets core components or introduces complex features, please open an issue first to discuss your implementation plan before starting development. See [contribute.md](https://github.com/selfxyz/self/blob/dev/contribute.md) for guidelines. + +## Description + +_A clear description of what you want to build or contribute._ + +## Motivation + +_Why is this feature useful? What problem does it solve?_ + +## Proposed Solution (optional) + +_If you have ideas on how to implement this, describe them here._ + +## Workspace (optional) + +_Which workspace(s) would this affect? (e.g., app, circuits, contracts, sdk/core, etc.)_ + +## Additional Context + +_Any other context, mockups, or examples._ From 8c30720a2931789b7b3cf38d1d39835b6aa04af2 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 12 Jan 2026 14:20:26 -0800 Subject: [PATCH 35/47] allow os target labels (#1594) --- .github/workflows/mobile-deploy-auto.yml | 30 ++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mobile-deploy-auto.yml b/.github/workflows/mobile-deploy-auto.yml index 6554a954d..f3aa90929 100644 --- a/.github/workflows/mobile-deploy-auto.yml +++ b/.github/workflows/mobile-deploy-auto.yml @@ -71,8 +71,34 @@ jobs: echo "📈 Version bump: build only" fi - # Always deploy both platforms for now (can be enhanced later) - echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT + # Determine platforms based on deploy labels + # If both deploy:ios and deploy:android labels exist, deploy both + # If neither label exists, deploy both (default behavior) + # If only one label exists, deploy only that platform + has_ios_label=false + has_android_label=false + + if [[ "$labels" =~ deploy:ios ]]; then + has_ios_label=true + fi + if [[ "$labels" =~ deploy:android ]]; then + has_android_label=true + fi + + if [[ "$has_ios_label" == "true" && "$has_android_label" == "true" ]]; then + echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT + echo "📱 Deploying both iOS and Android (both labels present)" + elif [[ "$has_ios_label" == "true" ]]; then + echo 'platforms=["ios"]' >> $GITHUB_OUTPUT + echo "📱 Deploying iOS only (deploy:ios label present)" + elif [[ "$has_android_label" == "true" ]]; then + echo 'platforms=["android"]' >> $GITHUB_OUTPUT + echo "📱 Deploying Android only (deploy:android label present)" + else + echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT + echo "📱 Deploying both iOS and Android (no platform labels, default behavior)" + fi + echo "should_deploy=true" >> $GITHUB_OUTPUT - name: Log deployment info From 57acda6680371b06f8e109893f828752288783f8 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 12 Jan 2026 15:03:07 -0800 Subject: [PATCH 36/47] bump build (#1595) --- app/version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.json b/app/version.json index 1b9ef12ac..903c5458e 100644 --- a/app/version.json +++ b/app/version.json @@ -1,6 +1,6 @@ { "ios": { - "build": 203, + "build": 204, "lastDeployed": "2026-01-12T16:10:12.854Z" }, "android": { From 8f0696149dc26a933b6e9753ebff0b506cea29cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:34:07 -0800 Subject: [PATCH 37/47] chore: bump mobile app version to 2.9.10 (#1596) Update build numbers and deployment timestamps after successful deployment. Co-authored-by: github-actions[bot] --- app/version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/version.json b/app/version.json index 903c5458e..12d4ce934 100644 --- a/app/version.json +++ b/app/version.json @@ -1,7 +1,7 @@ { "ios": { - "build": 204, - "lastDeployed": "2026-01-12T16:10:12.854Z" + "build": 205, + "lastDeployed": "2026-01-12T23:27:08.229Z" }, "android": { "build": 134, From 2939926c825daca60160d05d5526cbfa792057f5 Mon Sep 17 00:00:00 2001 From: Leszek Stachowski Date: Tue, 13 Jan 2026 20:02:33 +0100 Subject: [PATCH 38/47] fix: register document from ManageDocuments screen (#1597) * fix: register document from ManageDocuments screen * coderabbit suggestion --- .../management/ManageDocumentsScreen.tsx | 156 +++++++++++++----- 1 file changed, 118 insertions(+), 38 deletions(-) diff --git a/app/src/screens/documents/management/ManageDocumentsScreen.tsx b/app/src/screens/documents/management/ManageDocumentsScreen.tsx index 56785056f..260662c3a 100644 --- a/app/src/screens/documents/management/ManageDocumentsScreen.tsx +++ b/app/src/screens/documents/management/ManageDocumentsScreen.tsx @@ -8,7 +8,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Button, ScrollView, Spinner, Text, XStack, YStack } from 'tamagui'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Check, Eraser } from '@tamagui/lucide-icons'; +import { Check, Eraser, HousePlus } from '@tamagui/lucide-icons'; import type { DocumentCatalog, @@ -33,6 +33,8 @@ import { usePassport } from '@/providers/passportDataProvider'; import { extraYPadding } from '@/utils/styleUtils'; const PassportDataSelector = () => { + const navigation = + useNavigation>(); const selfClient = useSelfClient(); const { loadDocumentCatalog, @@ -73,7 +75,20 @@ const PassportDataSelector = () => { loadPassportDataInfo(); }, [loadPassportDataInfo]); - const handleDocumentSelection = async (documentId: string) => { + const handleDocumentSelection = async ( + documentId: string, + isRegistered: boolean | undefined, + ) => { + if (!isRegistered) { + Alert.alert( + 'Document not registered', + 'This document cannot be selected as active, because it is not registered. Click the add button next to it to register it first.', + [{ text: 'OK', style: 'cancel' }], + ); + + return; + } + await setSelectedDocument(documentId); // Reload to update UI without loading state for quick operations const catalog = await loadDocumentCatalog(); @@ -90,24 +105,40 @@ const PassportDataSelector = () => { await loadPassportDataInfo(); }; - const handleDeleteButtonPress = (documentId: string) => { - Alert.alert( - '⚠️ Delete Document ⚠️', - 'Are you sure you want to delete this document?\n\nThis document is already linked to your identity in Self Protocol and cannot be linked by another person.', - [ - { - text: 'Cancel', - style: 'cancel', + const handleRegisterDocument = async (documentId: string) => { + try { + await setSelectedDocument(documentId); + navigation.navigate('ConfirmBelonging', {}); + } catch (error) { + Alert.alert( + 'Registration Error', + 'Failed to prepare document for registration. Please try again.', + [{ text: 'OK', style: 'cancel' }], + ); + } + }; + + const handleDeleteButtonPress = ( + documentId: string, + isRegistered: boolean | undefined, + ) => { + const message = isRegistered + ? 'Are you sure you want to delete this document?\n\nThis document is already linked to your identity in Self Protocol and cannot be linked by another person.' + : 'Are you sure you want to delete this document?'; + + Alert.alert('⚠️ Delete Document ⚠️', message, [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + await handleDeleteSpecific(documentId); }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - await handleDeleteSpecific(documentId); - }, - }, - ], - ); + }, + ]); }; const getDisplayName = (documentType: string): string => { @@ -156,6 +187,16 @@ const PassportDataSelector = () => { } }; + const getDocumentBackgroundColor = ( + isSelected: boolean, + isRegistered: boolean | undefined, + ): string => { + if (!isRegistered) { + return '#ffebee'; // Light red for unregistered documents + } + return isSelected ? '$gray2' : 'white'; + }; + if (loading) { return ( @@ -196,6 +237,10 @@ const PassportDataSelector = () => { ); } + const hasUnregisteredDocuments = documentCatalog.documents.some( + doc => !doc.isRegistered, + ); + return ( { > Available Documents + {hasUnregisteredDocuments && ( + + + ⚠️ We've detected some documents that are not registered. In order + to use them, you'll have to register them first by clicking the plus + icon next to them. + + + )} {documentCatalog.documents.map((metadata: DocumentMetadata) => ( { : borderColor } borderRadius="$3" - backgroundColor={ - documentCatalog.selectedDocumentId === metadata.id - ? '$gray2' - : 'white' + backgroundColor={getDocumentBackgroundColor( + documentCatalog.selectedDocumentId === metadata.id, + metadata.isRegistered, + )} + onPress={() => + handleDocumentSelection(metadata.id, metadata.isRegistered) } - onPress={() => handleDocumentSelection(metadata.id)} pressStyle={{ opacity: 0.8 }} > { } borderColor={textBlack} borderWidth={1} - onPress={() => handleDocumentSelection(metadata.id)} + onPress={() => + handleDocumentSelection(metadata.id, metadata.isRegistered) + } > {documentCatalog.selectedDocumentId === metadata.id && ( @@ -256,19 +319,36 @@ const PassportDataSelector = () => { - + + {metadata.isRegistered !== true && ( + + )} + + ))} From 27672e52f6b72b1d47724105d336b25877a49485 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 13 Jan 2026 11:16:17 -0800 Subject: [PATCH 39/47] Add document processor behavior tests (#1599) * Add document processor integration tests * fix types --- packages/mobile-sdk-alpha/package.json | 2 + .../provingMachine.documentProcessor.test.ts | 920 ++++++++++++++++++ yarn.lock | 2 + 3 files changed, 924 insertions(+) create mode 100644 packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts diff --git a/packages/mobile-sdk-alpha/package.json b/packages/mobile-sdk-alpha/package.json index 2262c3301..a5a77be84 100644 --- a/packages/mobile-sdk-alpha/package.json +++ b/packages/mobile-sdk-alpha/package.json @@ -162,6 +162,7 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@openpassport/zk-kit-lean-imt": "^0.0.6", "@testing-library/react": "^14.1.2", "@types/react": "^18.3.4", "@types/react-dom": "^18.3.0", @@ -177,6 +178,7 @@ "eslint-plugin-sort-exports": "^0.9.1", "jsdom": "^25.0.1", "lottie-react-native": "7.2.2", + "poseidon-lite": "^0.3.0", "prettier": "^3.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts b/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts new file mode 100644 index 000000000..28b7b77d5 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts @@ -0,0 +1,920 @@ +// 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 { poseidon2 } from 'poseidon-lite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AadhaarData, PassportData } from '@selfxyz/common'; +import { + generateCommitment, + genMockIdDoc, + getCircuitNameFromPassportData, + getLeafDscTree, + isMRZDocument, +} from '@selfxyz/common/utils'; +import * as commonUtils from '@selfxyz/common/utils'; +import { generateCommitmentInAppAadhaar } from '@selfxyz/common/utils/passports/validate'; +import { AttestationIdHex } from '@selfxyz/common/utils/types'; + +import { PassportEvents, ProofEvents } from '../../src/constants/analytics'; +import * as documentUtils from '../../src/documents/utils'; +import { useProvingStore } from '../../src/proving/provingMachine'; +import { fetchAllTreesAndCircuits } from '../../src/stores'; +import type { SelfClient } from '../../src/types/public'; +import { actorMock } from './actorMock'; + +import { LeanIMT } from '@openpassport/zk-kit-lean-imt'; + +vi.mock('xstate', async () => { + const actual = await vi.importActual('xstate'); + return { + ...actual, + createActor: vi.fn(() => actorMock), + }; +}); + +vi.mock('../../src/documents/utils', async () => { + const actual = await vi.importActual('../../src/documents/utils'); + return { + ...actual, + loadSelectedDocument: vi.fn(), + storePassportData: vi.fn(), + clearPassportData: vi.fn(), + reStorePassportDataWithRightCSCA: vi.fn(), + markCurrentDocumentAsRegistered: vi.fn(), + }; +}); + +vi.mock('../../src/stores', async () => { + const actual = await vi.importActual('../../src/stores'); + return { + ...actual, + fetchAllTreesAndCircuits: vi.fn(), + }; +}); + +const createCommitmentTree = (commitments: string[]) => { + const tree = new LeanIMT((a, b) => poseidon2([a, b])); + if (commitments.length > 0) { + tree.insertMany(commitments.map(commitment => BigInt(commitment))); + } + return tree.export(); +}; + +const createDscTree = (leaves: string[]) => createCommitmentTree(leaves); + +const buildPassportFixture = (): PassportData => + ({ + mrz: 'P; + publicKeys?: string[]; +}) => ({ + passport: { + commitment_tree: commitmentTree, + dsc_tree: dscTree, + csca_tree: null, + deployed_circuits: deployedCircuits, + circuits_dns_mapping: null, + alternative_csca: alternativeCsca ?? {}, + ofac_trees: null, + fetch_all: vi.fn(), + fetch_deployed_circuits: vi.fn(), + fetch_circuits_dns_mapping: vi.fn(), + fetch_csca_tree: vi.fn(), + fetch_dsc_tree: vi.fn(), + fetch_identity_tree: vi.fn(), + fetch_alternative_csca: vi.fn(), + fetch_ofac_trees: vi.fn(), + }, + id_card: { + commitment_tree: commitmentTree, + dsc_tree: dscTree, + csca_tree: null, + deployed_circuits: deployedCircuits, + circuits_dns_mapping: null, + alternative_csca: alternativeCsca ?? {}, + ofac_trees: null, + fetch_all: vi.fn(), + fetch_deployed_circuits: vi.fn(), + fetch_circuits_dns_mapping: vi.fn(), + fetch_csca_tree: vi.fn(), + fetch_dsc_tree: vi.fn(), + fetch_identity_tree: vi.fn(), + fetch_alternative_csca: vi.fn(), + fetch_ofac_trees: vi.fn(), + }, + aadhaar: { + commitment_tree: commitmentTree, + dsc_tree: null, + csca_tree: null, + deployed_circuits: deployedCircuits, + circuits_dns_mapping: null, + public_keys: publicKeys ?? [], + ofac_trees: null, + fetch_all: vi.fn(), + fetch_deployed_circuits: vi.fn(), + fetch_circuits_dns_mapping: vi.fn(), + fetch_csca_tree: vi.fn(), + fetch_dsc_tree: vi.fn(), + fetch_identity_tree: vi.fn(), + fetch_alternative_csca: vi.fn(), + fetch_ofac_trees: vi.fn(), + }, +}); + +const createSelfClient = (protocolState: ReturnType) => + ({ + trackEvent: vi.fn(), + logProofEvent: vi.fn(), + emit: vi.fn(), + getPrivateKey: vi.fn().mockResolvedValue('123456789'), + getProvingState: () => useProvingStore.getState(), + getSelfAppState: () => ({ selfApp: null }), + getProtocolState: () => protocolState, + }) as unknown as SelfClient; + +describe('parseIDDocument', () => { + const loadSelectedDocumentMock = vi.mocked(documentUtils.loadSelectedDocument); + const storePassportDataMock = vi.mocked(documentUtils.storePassportData); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('parses passport data successfully and updates state with parsed result', async () => { + const passportData = genMockIdDoc({ idType: 'mock_passport' }) as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + const getSKIPEMSpy = vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + await useProvingStore.getState().parseIDDocument(selfClient); + + const state = useProvingStore.getState(); + expect(getSKIPEMSpy).toHaveBeenCalledWith('staging'); + expect(storePassportDataMock).toHaveBeenCalledWith(selfClient, state.passportData); + if (state.passportData && isMRZDocument(state.passportData)) { + expect(state.passportData.passportMetadata).toBeDefined(); + } + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_SUCCESS' }); + if (state.passportData && isMRZDocument(state.passportData)) { + expect(selfClient.trackEvent).toHaveBeenCalledWith( + PassportEvents.PASSPORT_PARSED, + expect.objectContaining({ + success: true, + country_code: state.passportData.passportMetadata?.countryCode, + }), + ); + } + }); + + it('handles missing passport data with PARSE_ERROR and analytics event', async () => { + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: genMockIdDoc({ idType: 'mock_passport' }) } as any); + + vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: null }); + + await useProvingStore.getState().parseIDDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(PassportEvents.PASSPORT_PARSE_FAILED, { + error: 'PassportData is not available', + }); + }); + + it('surfaces parsing failures when the DSC cannot be parsed', async () => { + const passportData = { + ...(genMockIdDoc({ idType: 'mock_passport' }) as PassportData), + dsc: 'invalid-certificate', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + await useProvingStore.getState().parseIDDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith( + PassportEvents.PASSPORT_PARSE_FAILED, + expect.objectContaining({ + error: expect.stringMatching(/asn\\.1|parsing/i), + }), + ); + }); + + it('continues when DSC metadata cannot be read and logs empty dsc payload', async () => { + const passportData = genMockIdDoc({ idType: 'mock_passport' }) as PassportData; + let metadataProxy: PassportData['passportMetadata']; + Object.defineProperty(passportData, 'passportMetadata', { + get() { + return metadataProxy; + }, + set(value) { + metadataProxy = new Proxy(value, { + get(target, prop) { + if (prop === 'dsc') { + throw new Error('dsc parse failed'); + } + return target[prop as keyof typeof target]; + }, + }); + }, + configurable: true, + }); + + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + await useProvingStore.getState().parseIDDocument(selfClient); + + const parsedEvent = vi + .mocked(selfClient.trackEvent) + .mock.calls.find(([event]) => event === PassportEvents.PASSPORT_PARSED)?.[1]; + + expect(parsedEvent).toEqual( + expect.objectContaining({ + dsc: {}, + }), + ); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_SUCCESS' }); + }); + + it('emits PARSE_ERROR when storing parsed passport data fails', async () => { + const passportData = genMockIdDoc({ idType: 'mock_passport' }) as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + storePassportDataMock.mockRejectedValue(new Error('storage unavailable')); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + await useProvingStore.getState().parseIDDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(PassportEvents.PASSPORT_PARSE_FAILED, { + error: 'storage unavailable', + }); + }); +}); + +describe('startFetchingData', () => { + const loadSelectedDocumentMock = vi.mocked(documentUtils.loadSelectedDocument); + const fetchAllTreesMock = vi.mocked(fetchAllTreesAndCircuits); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches trees and circuits for passport documents', async () => { + const passportData = { + ...(genMockIdDoc({ idType: 'mock_passport' }) as PassportData), + dsc_parsed: { authorityKeyIdentifier: 'KEY123' } as any, + documentCategory: 'passport', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, env: 'prod' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(fetchAllTreesMock).toHaveBeenCalledWith(selfClient, 'passport', 'prod', 'KEY123'); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_SUCCESS); + }); + + it('fetches trees and circuits for id cards', async () => { + const idCardData = { + ...(genMockIdDoc({ idType: 'mock_id_card' }) as PassportData), + dsc_parsed: { authorityKeyIdentifier: 'IDKEY' } as any, + documentCategory: 'id_card', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: idCardData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: idCardData, env: 'stg' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(fetchAllTreesMock).toHaveBeenCalledWith(selfClient, 'id_card', 'stg', 'IDKEY'); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' }); + }); + + it('fetches aadhaar protocol data via aadhaar fetcher', async () => { + const aadhaarData = genMockIdDoc({ idType: 'mock_aadhaar' }) as AadhaarData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: aadhaarData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: aadhaarData, env: 'prod' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(protocolState.aadhaar.fetch_all).toHaveBeenCalledWith('prod'); + expect(fetchAllTreesMock).not.toHaveBeenCalled(); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' }); + }); + + it('emits FETCH_ERROR when passport data is missing', async () => { + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: genMockIdDoc({ idType: 'mock_passport' }) } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: null }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, { + message: 'PassportData is not available', + }); + }); + + it('emits FETCH_ERROR when DSC data is missing for passports', async () => { + const passportData = { + ...(genMockIdDoc({ idType: 'mock_passport' }) as PassportData), + dsc_parsed: undefined, + documentCategory: 'passport', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, env: 'stg' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, { + message: 'Missing parsed DSC in passport data', + }); + }); + + it('emits FETCH_ERROR when protocol fetch fails', async () => { + const passportData = { + ...(genMockIdDoc({ idType: 'mock_passport' }) as PassportData), + dsc_parsed: { authorityKeyIdentifier: 'KEY123' } as any, + documentCategory: 'passport', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + fetchAllTreesMock.mockRejectedValue(new Error('network down')); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, env: 'prod' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, { + message: 'network down', + }); + }); +}); + +describe('validatingDocument', () => { + const loadSelectedDocumentMock = vi.mocked(documentUtils.loadSelectedDocument); + const clearPassportDataMock = vi.mocked(documentUtils.clearPassportData); + const reStorePassportDataWithRightCSCMock = vi.mocked(documentUtils.reStorePassportDataWithRightCSCA); + const markCurrentDocumentAsRegisteredMock = vi.mocked(documentUtils.markCurrentDocumentAsRegistered); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('clears data and emits PASSPORT_NOT_SUPPORTED when document is unsupported', async () => { + const passportData = buildPassportFixture(); + const unsupportedCircuits = { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: unsupportedCircuits, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret: '123456789', circuitType: 'register' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(clearPassportDataMock).toHaveBeenCalledWith(selfClient); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PASSPORT_NOT_SUPPORTED' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith( + PassportEvents.COMING_SOON, + expect.objectContaining({ status: 'registration_circuit_not_supported' }), + ); + }); + + it('validates disclose when the user is registered', async () => { + const passportData = buildPassportFixture(); + const secret = '123456789'; + const commitment = generateCommitment(secret, AttestationIdHex.passport, passportData); + const commitmentTree = createCommitmentTree([commitment]); + + const registerCircuit = getCircuitNameFromPassportData(passportData, 'register'); + const dscCircuit = getCircuitNameFromPassportData(passportData, 'dsc'); + const deployedCircuits = { + REGISTER: [registerCircuit], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [dscCircuit], + DSC_ID: [], + }; + + const protocolState = buildProtocolState({ + commitmentTree, + dscTree: null, + deployedCircuits, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'disclose'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret, circuitType: 'disclose' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'VALIDATION_SUCCESS' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.VALIDATION_SUCCESS); + }); + + it('emits PASSPORT_DATA_NOT_FOUND when disclose document is not registered', async () => { + const passportData = buildPassportFixture(); + const secret = '123456789'; + const commitmentTree = createCommitmentTree([]); + const registerCircuit = getCircuitNameFromPassportData(passportData, 'register'); + const dscCircuit = getCircuitNameFromPassportData(passportData, 'dsc'); + const deployedCircuits = { + REGISTER: [registerCircuit], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [dscCircuit], + DSC_ID: [], + }; + + const protocolState = buildProtocolState({ + commitmentTree, + dscTree: null, + deployedCircuits, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'disclose'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret, circuitType: 'disclose' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PASSPORT_DATA_NOT_FOUND' }); + }); + + it('restores data when aadhaar is already registered with alternative keys', async () => { + const aadhaarData = genMockIdDoc({ idType: 'mock_aadhaar' }) as AadhaarData; + const secret = '123456789'; + const { commitment_list: commitmentList } = generateCommitmentInAppAadhaar( + secret, + AttestationIdHex.aadhaar, + aadhaarData, + { + public_key_0: aadhaarData.publicKey, + }, + ); + const commitmentTree = createCommitmentTree([commitmentList[0]]); + const deployedCircuits = { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [], + DSC_ID: [], + }; + + const protocolState = buildProtocolState({ + commitmentTree, + dscTree: null, + deployedCircuits, + publicKeys: [aadhaarData.publicKey], + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: aadhaarData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: aadhaarData, secret, circuitType: 'register' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(reStorePassportDataWithRightCSCMock).toHaveBeenCalledWith(selfClient, aadhaarData, aadhaarData.publicKey); + expect(markCurrentDocumentAsRegisteredMock).toHaveBeenCalledWith(selfClient); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'ALREADY_REGISTERED' }); + }); + + it('routes to account recovery when nullifier is on chain', async () => { + const passportData = buildPassportFixture(); + const secret = '123456789'; + const registerCircuit = getCircuitNameFromPassportData(passportData, 'register'); + const dscCircuit = getCircuitNameFromPassportData(passportData, 'dsc'); + const deployedCircuits = { + REGISTER: [registerCircuit], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [dscCircuit], + DSC_ID: [], + }; + + const protocolState = buildProtocolState({ + commitmentTree: createCommitmentTree([]), + dscTree: null, + deployedCircuits, + alternativeCsca: {}, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: true }), + } as Response), + ); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret, circuitType: 'register' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'ACCOUNT_RECOVERY_CHOICE' }); + + globalThis.fetch = originalFetch; + }); + + it('switches to register circuit when DSC is already in the tree', async () => { + const passportData = buildPassportFixture(); + const secret = '123456789'; + const registerCircuit = getCircuitNameFromPassportData(passportData, 'register'); + const dscCircuit = getCircuitNameFromPassportData(passportData, 'dsc'); + const deployedCircuits = { + REGISTER: [registerCircuit], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [dscCircuit], + DSC_ID: [], + }; + const dscLeaf = getLeafDscTree(passportData.dsc_parsed!, passportData.csca_parsed!); + const dscTree = createDscTree([dscLeaf]); + + const protocolState = buildProtocolState({ + commitmentTree: createCommitmentTree([]), + dscTree, + deployedCircuits, + alternativeCsca: {}, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: false }), + } as Response), + ); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret, circuitType: 'dsc' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(useProvingStore.getState().circuitType).toBe('register'); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'VALIDATION_SUCCESS' }); + + globalThis.fetch = originalFetch; + }); + + it('emits VALIDATION_ERROR when validation throws', async () => { + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: buildPassportFixture() } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: null }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'VALIDATION_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.VALIDATION_FAILED, { + message: 'PassportData is not available', + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index d9e270a75..85f78edea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8700,6 +8700,7 @@ __metadata: resolution: "@selfxyz/mobile-sdk-alpha@workspace:packages/mobile-sdk-alpha" dependencies: "@babel/runtime": "npm:^7.28.3" + "@openpassport/zk-kit-lean-imt": "npm:^0.0.6" "@selfxyz/common": "workspace:^" "@selfxyz/euclid": "npm:^0.6.1" "@testing-library/react": "npm:^14.1.2" @@ -8719,6 +8720,7 @@ __metadata: jsdom: "npm:^25.0.1" lottie-react-native: "npm:7.2.2" node-forge: "npm:^1.3.1" + poseidon-lite: "npm:^0.3.0" prettier: "npm:^3.5.3" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" From bcbb8affd4eb27b0e315de88ab45ebcb4deb5c11 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Wed, 14 Jan 2026 03:11:48 +0530 Subject: [PATCH 40/47] SELF-1768: fix deeplink navigation (#1598) * fix deeplink navigation * fix tests * fix typing --------- Co-authored-by: Justin Hernandez --- app/src/navigation/deeplinks.ts | 46 ++++++++++++++++------ app/tests/src/navigation/deeplinks.test.ts | 12 ++++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/app/src/navigation/deeplinks.ts b/app/src/navigation/deeplinks.ts index af4cd79ce..b2d803644 100644 --- a/app/src/navigation/deeplinks.ts +++ b/app/src/navigation/deeplinks.ts @@ -9,6 +9,7 @@ import { countries } from '@selfxyz/common/constants/countries'; import type { IdDocInput } from '@selfxyz/common/utils'; import type { SelfClient } from '@selfxyz/mobile-sdk-alpha'; +import type { RootStackParamList } from '@/navigation'; import { navigationRef } from '@/navigation'; import useUserStore from '@/stores/userStore'; import { IS_DEV_MODE } from '@/utils/devUtils'; @@ -108,6 +109,28 @@ export const getAndClearQueuedUrl = (): string | null => { return url; }; +const safeNavigate = ( + navigationState: ReturnType, +): void => { + const targetScreen = navigationState.routes[1]?.name as + | keyof RootStackParamList + | undefined; + + const currentRoute = navigationRef.getCurrentRoute(); + const isColdLaunch = currentRoute?.name === 'Splash'; + + if (!isColdLaunch && targetScreen) { + // Use object syntax to satisfy TypeScript's strict typing for navigate + // The params will be undefined for screens that don't require them + navigationRef.navigate({ + name: targetScreen, + params: undefined, + } as Parameters[0]); + } else { + navigationRef.reset(navigationState); + } +}; + export const handleUrl = (selfClient: SelfClient, uri: string) => { const validatedParams = parseAndValidateUrlParams(uri); const { @@ -125,7 +148,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().setSelfApp(selfAppJson); selfClient.getSelfAppState().startAppListener(selfAppJson.sessionId); - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState( 'ProvingScreenRouter', correctParentScreen, @@ -137,7 +160,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { if (IS_DEV_MODE) { console.error('Error parsing selfApp:', error); } - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen), ); } @@ -145,7 +168,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().cleanSelfApp(); selfClient.getSelfAppState().startAppListener(sessionId); - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('ProvingScreenRouter', correctParentScreen), ); } else if (mock_passport) { @@ -175,25 +198,26 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { }); // Reset navigation stack with correct parent -> MockDataDeepLink - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('MockDataDeepLink', correctParentScreen), ); } catch (error) { if (IS_DEV_MODE) { console.error('Error parsing mock_passport data or navigating:', error); } - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen), ); } } else if (referrer && typeof referrer === 'string') { useUserStore.getState().setDeepLinkReferrer(referrer); - // Navigate to HomeScreen - it will show confirmation modal and then navigate to GratificationScreen - navigationRef.reset({ - index: 0, - routes: [{ name: 'Home' }], - }); + const currentRoute = navigationRef.getCurrentRoute(); + if (currentRoute?.name === 'Home') { + // Already on Home, no navigation needed - the modal will show automatically + } else { + safeNavigate(createDeeplinkNavigationState('Home', 'Home')); + } } else if (Platform.OS === 'web') { // TODO: web handle links if we need to idk if we do // For web, we can handle the URL some other way if we dont do this loading app in web always navigates to QRCodeTrouble @@ -211,7 +235,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { 'No sessionId, selfApp or valid OAuth parameters found in the data', ); } - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen), ); } diff --git a/app/tests/src/navigation/deeplinks.test.ts b/app/tests/src/navigation/deeplinks.test.ts index 7ce0027e1..b73f87b6f 100644 --- a/app/tests/src/navigation/deeplinks.test.ts +++ b/app/tests/src/navigation/deeplinks.test.ts @@ -36,6 +36,7 @@ jest.mock('@/navigation', () => ({ navigate: jest.fn(), isReady: jest.fn(() => true), reset: jest.fn(), + getCurrentRoute: jest.fn(), }, })); @@ -66,6 +67,10 @@ describe('deeplinks', () => { setDeepLinkUserDetails, }); mockPlatform.OS = 'ios'; + + // Setup default getCurrentRoute mock to return Splash (cold launch scenario) + const { navigationRef } = require('@/navigation'); + navigationRef.getCurrentRoute.mockReturnValue({ name: 'Splash' }); }); describe('handleUrl', () => { @@ -156,9 +161,10 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); // Should navigate to HomeScreen, which will show confirmation modal + // During cold launch (Splash screen), reset is called with full navigation state expect(navigationRef.reset).toHaveBeenCalledWith({ - index: 0, - routes: [{ name: 'Home' }], + index: 1, + routes: [{ name: 'Home' }, { name: 'Home' }], }); }); @@ -598,7 +604,7 @@ describe('deeplinks', () => { mockLinking.getInitialURL.mockResolvedValue(undefined as any); mockLinking.addEventListener.mockReturnValue({ remove }); - const cleanup = setupUniversalLinkListenerInNavigation(); + const cleanup = setupUniversalLinkListenerInNavigation({} as SelfClient); expect(mockLinking.addEventListener).toHaveBeenCalled(); cleanup(); expect(remove).toHaveBeenCalled(); From 2c0a03ac4b73d7d6e98606f8e45764678d6d24b4 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 13 Jan 2026 13:51:01 -0800 Subject: [PATCH 41/47] update text (#1600) --- .../account/settings/ShowRecoveryPhraseScreen.tsx | 6 ++---- .../screens/onboarding/SaveRecoveryPhraseScreen.tsx | 4 ++-- app/src/utils/crypto/mnemonic.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx b/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx index 54b6adef8..33a0ae2bf 100644 --- a/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx +++ b/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx @@ -15,6 +15,7 @@ import Mnemonic from '@/components/Mnemonic'; import useMnemonic from '@/hooks/useMnemonic'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import { useSettingStore } from '@/stores/settingStore'; +import { getRecoveryPhraseWarningMessage } from '@/utils/crypto/mnemonic'; import { IS_EUCLID_ENABLED } from '@/utils/devUtils'; function useCopyRecoveryPhrase(mnemonic: string[] | undefined) { @@ -89,10 +90,7 @@ const ShowRecoveryPhraseScreen: React.FC & { gap={20} > - - This phrase is the only way to recover your account. Keep it secret, - keep it safe. - + {getRecoveryPhraseWarningMessage()} ); diff --git a/app/src/screens/onboarding/SaveRecoveryPhraseScreen.tsx b/app/src/screens/onboarding/SaveRecoveryPhraseScreen.tsx index de62cca5e..6a732bb45 100644 --- a/app/src/screens/onboarding/SaveRecoveryPhraseScreen.tsx +++ b/app/src/screens/onboarding/SaveRecoveryPhraseScreen.tsx @@ -23,6 +23,7 @@ import useMnemonic from '@/hooks/useMnemonic'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import { STORAGE_NAME } from '@/services/cloud-backup'; import { useSettingStore } from '@/stores/settingStore'; +import { getRecoveryPhraseWarningMessage } from '@/utils/crypto/mnemonic'; const SaveRecoveryPhraseScreen: React.FC = () => { const [userHasSeenMnemonic, setUserHasSeenMnemonic] = useState(false); @@ -55,8 +56,7 @@ const SaveRecoveryPhraseScreen: React.FC = () => { Save your recovery phrase - This phrase is the only way to recover your account. Keep it secret, - keep it safe. + {getRecoveryPhraseWarningMessage()} Date: Tue, 13 Jan 2026 14:08:50 -0800 Subject: [PATCH 42/47] chore: bump version to 2.9.11 (#1601) * chore: bump version to 2.9.11 * fix linting --- app/android/app/build.gradle | 2 +- app/ios/OpenPassport/Info.plist | 2 +- app/ios/Self.xcodeproj/project.pbxproj | 4 ++-- app/package.json | 2 +- .../screens/documents/management/ManageDocumentsScreen.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index f1c4e75c6..5ccd0a476 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -135,7 +135,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 121 - versionName "2.9.10" + versionName "2.9.11" manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp'] externalNativeBuild { cmake { diff --git a/app/ios/OpenPassport/Info.plist b/app/ios/OpenPassport/Info.plist index 0aee45559..cb975feea 100644 --- a/app/ios/OpenPassport/Info.plist +++ b/app/ios/OpenPassport/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.9.10 + 2.9.11 CFBundleSignature ???? CFBundleURLTypes diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index 6ee7b5d90..1917c7aac 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -546,7 +546,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.10; + MARKETING_VERSION = 2.9.11; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -686,7 +686,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.10; + MARKETING_VERSION = 2.9.11; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/package.json b/app/package.json index 524d5c329..e1f3bdc3e 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@selfxyz/mobile-app", - "version": "2.9.10", + "version": "2.9.11", "private": true, "type": "module", "scripts": { diff --git a/app/src/screens/documents/management/ManageDocumentsScreen.tsx b/app/src/screens/documents/management/ManageDocumentsScreen.tsx index 260662c3a..f1aef3eb1 100644 --- a/app/src/screens/documents/management/ManageDocumentsScreen.tsx +++ b/app/src/screens/documents/management/ManageDocumentsScreen.tsx @@ -109,7 +109,7 @@ const PassportDataSelector = () => { try { await setSelectedDocument(documentId); navigation.navigate('ConfirmBelonging', {}); - } catch (error) { + } catch { Alert.alert( 'Registration Error', 'Failed to prepare document for registration. Please try again.', From ac4921a458df925925f791337165248f86031cbf Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 13 Jan 2026 14:31:47 -0800 Subject: [PATCH 43/47] Require real document for settings options (#1603) --- app/src/screens/account/settings/SettingsScreen.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx index f9ea00ce2..cb53ccce3 100644 --- a/app/src/screens/account/settings/SettingsScreen.tsx +++ b/app/src/screens/account/settings/SettingsScreen.tsx @@ -187,22 +187,16 @@ const SettingsScreen: React.FC = () => { const screenRoutes = useMemo(() => { 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; - } - const shouldHideCloudBackup = Platform.OS === 'android'; + const hasConfirmedRealDocument = hasRealDocument === true; - // Only filter out document-related routes if we've confirmed user has no real documents return baseRoutes.filter(([, , route]) => { if (DOCUMENT_DEPENDENT_ROUTES.includes(route)) { - return false; + return hasConfirmedRealDocument; } if (shouldHideCloudBackup && route === CLOUD_BACKUP_ROUTE) { - return false; + return hasConfirmedRealDocument; } return true; From 19735fb02b99a5cd21ed85f199e2a56f326408cd Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 13 Jan 2026 14:32:22 -0800 Subject: [PATCH 44/47] clean up dev settings (#1604) --- app/src/screens/dev/DevSettingsScreen.tsx | 192 ++++++++++++---------- 1 file changed, 108 insertions(+), 84 deletions(-) diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index d40e48c4f..8eba89e13 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -33,7 +33,6 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; import BugIcon from '@/assets/icons/bug_icon.svg'; -import IdIcon from '@/assets/icons/id_icon.svg'; import WarningIcon from '@/assets/icons/warning.svg'; import type { RootStackParamList } from '@/navigation'; import { navigationScreens } from '@/navigation'; @@ -287,6 +286,110 @@ const ScreenSelector = ({}) => { ); }; +const LogLevelSelector = ({ + currentLevel, + onSelect, +}: { + currentLevel: string; + onSelect: (level: 'debug' | 'info' | 'warn' | 'error') => void; +}) => { + const [open, setOpen] = useState(false); + + const logLevels = ['debug', 'info', 'warn', 'error'] as const; + + return ( + <> + + + + + + + + + Select log level + + + + + {logLevels.map(level => ( + { + setOpen(false); + onSelect(level); + }} + > + + + {level.toUpperCase()} + + {currentLevel === level && ( + + )} + + + ))} + + + + + + ); +}; + const DevSettingsScreen: React.FC = ({}) => { const { clearDocumentCatalogForMigrationTesting } = usePassport(); const clearPointEvents = usePointEventStore(state => state.clearEvents); @@ -547,57 +650,6 @@ const DevSettingsScreen: React.FC = ({}) => { paddingTop="$4" paddingBottom={paddingBottom} > - } - title="Manage ID Documents" - description="Register new IDs and generate test IDs" - > - {[ - { - label: 'Manage available IDs', - onPress: () => { - navigation.navigate('ManageDocuments'); - }, - }, - { - label: 'Generate Test ID', - onPress: () => { - navigation.navigate('CreateMock'); - }, - }, - { - label: 'Scan new ID Document', - onPress: () => { - navigation.navigate('DocumentOnboarding'); - }, - }, - ].map(({ label, onPress }) => ( - - - - ))} - - } title="Debug Shortcuts" @@ -696,38 +748,10 @@ const DevSettingsScreen: React.FC = ({}) => { title="Log Level" description="Configure logging verbosity" > - - {(['debug', 'info', 'warn', 'error'] as const).map(level => ( - - ))} - + Date: Mon, 5 Jan 2026 15:31:16 +0100 Subject: [PATCH 45/47] Enhance CI workflow for QR code SDK by adding conditional job execution. Introduced a check to determine if relevant files have changed before running build and quality checks, improving efficiency for dev branch deployments. --- .github/workflows/qrcode-sdk-ci.yml | 47 ++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/qrcode-sdk-ci.yml b/.github/workflows/qrcode-sdk-ci.yml index 10202c07e..22b16fcb0 100644 --- a/.github/workflows/qrcode-sdk-ci.yml +++ b/.github/workflows/qrcode-sdk-ci.yml @@ -14,15 +14,43 @@ on: - dev - staging - main - paths: - - "sdk/qrcode/**" - - "common/**" - - ".github/workflows/qrcode-sdk-ci.yml" - - ".github/actions/**" jobs: + check_changes: + runs-on: ubuntu-slim + outputs: + should_run: ${{ steps.filter.outputs.should_run }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check if should run + id: filter + run: | + set -e + if [[ "${{ github.base_ref }}" == "main" ]] || [[ "${{ github.base_ref }}" == "staging" ]]; then + echo "should_run=true" >> $GITHUB_OUTPUT + echo "Running for ${{ github.base_ref }} - no path filter" + else + # For dev branch, check if relevant files changed + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { + echo "Error: Failed to diff against base branch" + exit 1 + } + if echo "$CHANGED_FILES" | grep -qE "^(sdk/qrcode/|common/|\.github/workflows/qrcode-sdk-ci\.yml|\.github/actions/)"; then + echo "should_run=true" >> $GITHUB_OUTPUT + echo "Running for dev - relevant files changed" + else + echo "should_run=false" >> $GITHUB_OUTPUT + echo "Skipping for dev - no relevant files changed" + fi + fi + # Build dependencies once and cache for other jobs build: + needs: check_changes + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -83,7 +111,8 @@ jobs: # Quality checks job quality-checks: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' steps: - uses: actions/checkout@v6 - name: Read and sanitize Node.js version @@ -151,7 +180,8 @@ jobs: # Build verification job build-verification: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' steps: - uses: actions/checkout@v6 - name: Read and sanitize Node.js version @@ -213,7 +243,8 @@ jobs: # Integration test job integration-test: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' steps: - uses: actions/checkout@v6 - name: Read and sanitize Node.js version From 7cba76f02fa6ffde212f9475800facd284098a3d Mon Sep 17 00:00:00 2001 From: Javier Cortejoso Date: Mon, 5 Jan 2026 15:33:33 +0100 Subject: [PATCH 46/47] Enhance CI workflow by adding a conditional check for relevant file changes before executing jobs. This update improves efficiency for pull requests on the dev branch while maintaining execution for main and staging branches. --- .github/workflows/core-sdk-ci.yml | 51 ++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/workflows/core-sdk-ci.yml b/.github/workflows/core-sdk-ci.yml index 5d9166588..0834c6426 100644 --- a/.github/workflows/core-sdk-ci.yml +++ b/.github/workflows/core-sdk-ci.yml @@ -2,14 +2,46 @@ name: Core SDK CI on: pull_request: - paths: - - "sdk/core/**" - - "common/**" - - ".github/workflows/core-sdk-ci.yml" - - ".github/actions/**" + branches: + - dev + - staging + - main jobs: + check_changes: + runs-on: ubuntu-slim + outputs: + should_run: ${{ steps.filter.outputs.should_run }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check if should run + id: filter + run: | + set -e + if [[ "${{ github.base_ref }}" == "main" ]] || [[ "${{ github.base_ref }}" == "staging" ]]; then + echo "should_run=true" >> $GITHUB_OUTPUT + echo "Running for ${{ github.base_ref }} - no path filter" + else + # For dev branch, check if relevant files changed + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { + echo "Error: Failed to diff against base branch" + exit 1 + } + if echo "$CHANGED_FILES" | grep -qE "^(sdk/core/|common/|\.github/workflows/core-sdk-ci\.yml|\.github/actions/)"; then + echo "should_run=true" >> $GITHUB_OUTPUT + echo "Running for dev - relevant files changed" + else + echo "should_run=false" >> $GITHUB_OUTPUT + echo "Skipping for dev - no relevant files changed" + fi + fi + build: + needs: check_changes + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -34,7 +66,8 @@ jobs: lint: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' permissions: contents: read steps: @@ -63,7 +96,8 @@ jobs: types: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' permissions: contents: read steps: @@ -92,7 +126,8 @@ jobs: test: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' permissions: contents: read steps: From ec68519322e6848123ce5941631c695434ee1812 Mon Sep 17 00:00:00 2001 From: Javier Cortejoso Date: Thu, 15 Jan 2026 09:21:58 +0100 Subject: [PATCH 47/47] Enhance CI workflows: fetch base branch for accurate file comparison --- .github/workflows/circuits.yml | 2 ++ .github/workflows/contracts.yml | 2 ++ .github/workflows/core-sdk-ci.yml | 2 ++ .github/workflows/qrcode-sdk-ci.yml | 2 ++ 4 files changed, 8 insertions(+) diff --git a/.github/workflows/circuits.yml b/.github/workflows/circuits.yml index 021753870..7f34ced8a 100644 --- a/.github/workflows/circuits.yml +++ b/.github/workflows/circuits.yml @@ -24,6 +24,8 @@ jobs: echo "Running for ${{ github.base_ref }} - no path filter" else # For dev branch, check if circuits files changed + # Fetch the base branch to ensure it's available for comparison + git fetch origin ${{ github.base_ref }} --depth=1 CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { echo "Error: Failed to diff against base branch" exit 1 diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 2c41d18a8..86dbf375a 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -29,6 +29,8 @@ jobs: echo "Running for ${{ github.base_ref }} - no path filter" else # For dev branch, check if contracts or common files changed + # Fetch the base branch to ensure it's available for comparison + git fetch origin ${{ github.base_ref }} --depth=1 CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { echo "Error: Failed to diff against base branch" exit 1 diff --git a/.github/workflows/core-sdk-ci.yml b/.github/workflows/core-sdk-ci.yml index 0834c6426..180bc0e95 100644 --- a/.github/workflows/core-sdk-ci.yml +++ b/.github/workflows/core-sdk-ci.yml @@ -26,6 +26,8 @@ jobs: echo "Running for ${{ github.base_ref }} - no path filter" else # For dev branch, check if relevant files changed + # Fetch the base branch to ensure it's available for comparison + git fetch origin ${{ github.base_ref }} --depth=1 CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { echo "Error: Failed to diff against base branch" exit 1 diff --git a/.github/workflows/qrcode-sdk-ci.yml b/.github/workflows/qrcode-sdk-ci.yml index 22b16fcb0..1d5353af5 100644 --- a/.github/workflows/qrcode-sdk-ci.yml +++ b/.github/workflows/qrcode-sdk-ci.yml @@ -34,6 +34,8 @@ jobs: echo "Running for ${{ github.base_ref }} - no path filter" else # For dev branch, check if relevant files changed + # Fetch the base branch to ensure it's available for comparison + git fetch origin ${{ github.base_ref }} --depth=1 CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { echo "Error: Failed to diff against base branch" exit 1