SELF-483: Enable backup recovery prompts (#834)

* Guard recovery prompts

* refactor(app): gate recovery prompts with allow list (#1251)

* fix typing

* fix header

* fix app loading

* fix tests

* Limit recovery prompts to home allowlist (#1460)

* fix test

* fix typing pipeline

* format and fix linting and tests

* tests pass

* fix tests

* split up testing

* save wip

* save button fix

* fix count

* fix modal width

* remove consologging

* remove depcrecated login count

* linting

* lint

* early return
This commit is contained in:
Justin Hernandez
2025-12-05 21:34:50 -08:00
committed by GitHub
parent 89b16486ee
commit 202d0f8122
21 changed files with 1112 additions and 561 deletions

View File

@@ -87,7 +87,7 @@ GEM
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
connection_pool (3.0.2)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@@ -222,7 +222,7 @@ GEM
i18n (1.14.7)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.16.0)
json (2.17.1)
jwt (2.10.2)
base64
logger (1.7.0)

View File

@@ -31,6 +31,7 @@ module.exports = {
moduleNameMapper: {
'^@env$': '<rootDir>/tests/__setup__/@env.js',
'\\.svg$': '<rootDir>/tests/__setup__/svgMock.js',
'\\.(png|jpg|jpeg|gif|webp)$': '<rootDir>/tests/__setup__/imageMock.js',
'^@/(.*)$': '<rootDir>/src/$1',
'^@$': '<rootDir>/src',
'^@tests/(.*)$': '<rootDir>/tests/src/$1',

View File

@@ -21,21 +21,136 @@ const mockPixelRatio = {
global.PixelRatio = mockPixelRatio;
// Also make it available for require() calls
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === 'react-native') {
const RN = originalRequire.apply(this, arguments);
if (!RN.PixelRatio || !RN.PixelRatio.getFontScale) {
RN.PixelRatio = mockPixelRatio;
}
return RN;
}
return originalRequire.apply(this, arguments);
// Define NativeModules early so it's available for react-native mock
// This will be assigned to global.NativeModules later, but we define it here
// so the react-native mock can reference it
const NativeModules = {
PassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
ReactNativeBiometrics: {
isSensorAvailable: jest.fn().mockResolvedValue({
available: true,
biometryType: 'TouchID',
}),
createKeys: jest.fn().mockResolvedValue({ publicKey: 'mock-public-key' }),
deleteKeys: jest.fn().mockResolvedValue(true),
createSignature: jest
.fn()
.mockResolvedValue({ signature: 'mock-signature' }),
simplePrompt: jest.fn().mockResolvedValue({ success: true }),
},
NativeLoggerBridge: {
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
RNPassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
};
// Assign to global so it's available everywhere
global.NativeModules = NativeModules;
// Mock react-native comprehensively - single source of truth for all tests
// Note: NativeModules will be defined later and assigned to global.NativeModules
// This mock accesses it at runtime via global.NativeModules
jest.mock('react-native', () => {
// Create AppState mock with listener tracking
// Expose listeners array globally so tests can access it
const appStateListeners = [];
global.mockAppStateListeners = appStateListeners;
const mockAppState = {
currentState: 'active',
addEventListener: jest.fn((eventType, handler) => {
appStateListeners.push(handler);
return {
remove: () => {
const index = appStateListeners.indexOf(handler);
if (index >= 0) {
appStateListeners.splice(index, 1);
}
},
};
}),
};
return {
__esModule: true,
AppState: mockAppState,
Platform: {
OS: 'ios',
select: jest.fn(obj => obj.ios || obj.default),
Version: 14,
},
// NativeModules is defined above and assigned to global.NativeModules
// Use getter to access it at runtime (jest.mock is hoisted)
get NativeModules() {
return global.NativeModules || {};
},
NativeEventEmitter: jest.fn().mockImplementation(nativeModule => {
return {
addListener: jest.fn(),
removeListener: jest.fn(),
removeAllListeners: jest.fn(),
emit: jest.fn(),
};
}),
PixelRatio: mockPixelRatio,
Dimensions: {
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
},
Linking: {
getInitialURL: jest.fn().mockResolvedValue(null),
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
removeEventListener: jest.fn(),
openURL: jest.fn().mockResolvedValue(undefined),
canOpenURL: jest.fn().mockResolvedValue(true),
},
StyleSheet: {
create: jest.fn(styles => styles),
flatten: jest.fn(style => style),
hairlineWidth: 1,
absoluteFillObject: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
View: 'View',
Text: 'Text',
ScrollView: 'ScrollView',
TouchableOpacity: 'TouchableOpacity',
TouchableHighlight: 'TouchableHighlight',
Image: 'Image',
ActivityIndicator: 'ActivityIndicator',
SafeAreaView: 'SafeAreaView',
requireNativeComponent: jest.fn(name => {
// Return a mock component function for any native component
const MockNativeComponent = jest.fn(props => props.children || null);
MockNativeComponent.displayName = `Mock(${name})`;
return MockNativeComponent;
}),
};
});
require('react-native-gesture-handler/jestSetup');
// Mock NativeAnimatedHelper - using virtual mock during RN 0.76.9 prep phase
@@ -56,6 +171,23 @@ global.__fbBatchedBridgeConfig = {
// Set up global React Native test environment
global.__DEV__ = true;
// Set up global mock navigation ref for tests
global.mockNavigationRef = {
isReady: jest.fn(() => true),
getCurrentRoute: jest.fn(() => ({ name: 'Home' })),
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
addListener: jest.fn(() => jest.fn()),
removeListener: jest.fn(),
};
// Load grouped mocks
require('./tests/__setup__/mocks/navigation');
require('./tests/__setup__/mocks/ui');
// Mock TurboModuleRegistry to provide required native modules for BOTH main app and mobile-sdk-alpha
jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => ({
getEnforcing: jest.fn(name => {
@@ -130,21 +262,44 @@ jest.mock(
startDetecting: jest.fn(),
};
const RN = jest.requireActual('react-native');
// Override the PixelRatio immediately
RN.PixelRatio = PixelRatio;
// Make sure both the default and named exports work
const mockedRN = {
...RN,
// Return a simple object with all the mocks we need
// Avoid nested requireActual/requireMock to prevent OOM in CI
return {
__esModule: true,
PixelRatio,
default: {
...RN,
PixelRatio,
Platform: {
OS: 'ios',
select: jest.fn(obj => obj.ios || obj.default),
Version: 14,
},
Dimensions: {
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
},
StyleSheet: {
create: jest.fn(styles => styles),
flatten: jest.fn(style => style),
hairlineWidth: 1,
absoluteFillObject: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
View: 'View',
Text: 'Text',
ScrollView: 'ScrollView',
TouchableOpacity: 'TouchableOpacity',
requireNativeComponent: jest.fn(name => {
const MockNativeComponent = jest.fn(props => props.children || null);
MockNativeComponent.displayName = `Mock(${name})`;
return MockNativeComponent;
}),
};
return mockedRN;
},
{ virtual: true },
);
@@ -296,12 +451,19 @@ jest.mock('react-native-gesture-handler', () => {
const MockFlatList = jest.fn(props => null);
return {
...jest.requireActual('react-native-gesture-handler/jestSetup'),
// Provide gesture handler mock without requireActual to avoid OOM
GestureHandlerRootView: ({ children }) => children,
ScrollView: MockScrollView,
TouchableOpacity: MockTouchableOpacity,
TouchableHighlight: MockTouchableHighlight,
FlatList: MockFlatList,
Directions: {},
State: {},
Swipeable: jest.fn(() => null),
DrawerLayout: jest.fn(() => null),
PanGestureHandler: jest.fn(() => null),
TapGestureHandler: jest.fn(() => null),
LongPressGestureHandler: jest.fn(() => null),
};
});
@@ -750,20 +912,8 @@ jest.mock('react-native-passport-reader', () => {
};
});
// Mock NativeModules without requiring react-native to avoid memory issues
// Create a minimal NativeModules mock for PassportReader
const NativeModules = {
PassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
};
// Make it available globally for any code that expects it
global.NativeModules = NativeModules;
// NativeModules is already defined at the top of the file and assigned to global.NativeModules
// No need to redefine it here
// Mock @/integrations/nfc/passportReader to properly expose the interface expected by tests
jest.mock('./src/integrations/nfc/passportReader', () => {
@@ -1006,226 +1156,60 @@ jest.mock('react-native-svg', () => {
});
// Mock React Navigation
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
// Mock react-native-biometrics to prevent NativeModules errors
jest.mock('react-native-biometrics', () => {
class MockReactNativeBiometrics {
constructor(options) {
// Constructor accepts options but doesn't need to do anything
}
isSensorAvailable = jest.fn().mockResolvedValue({
available: true,
biometryType: 'TouchID',
});
createKeys = jest.fn().mockResolvedValue({ publicKey: 'mock-public-key' });
deleteKeys = jest.fn().mockResolvedValue(true);
createSignature = jest
.fn()
.mockResolvedValue({ signature: 'mock-signature' });
simplePrompt = jest.fn().mockResolvedValue({ success: true });
}
return {
...actualNav,
useFocusEffect: jest.fn(callback => {
// Immediately invoke the effect for testing without requiring a container
return callback();
}),
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
})),
createNavigationContainerRef: jest.fn(() => ({
current: null,
getCurrentRoute: jest.fn(),
})),
createStaticNavigation: jest.fn(() => ({ displayName: 'MockNavigation' })),
__esModule: true,
default: MockReactNativeBiometrics,
};
});
jest.mock('@react-navigation/native-stack', () => ({
createNativeStackNavigator: jest.fn(() => ({
displayName: 'MockStackNavigator',
})),
createNavigatorFactory: jest.fn(),
// Mock NativeAppState native module to prevent getCurrentAppState errors
jest.mock('react-native/Libraries/AppState/NativeAppState', () => ({
__esModule: true,
default: {
getConstants: jest.fn(() => ({ initialAppState: 'active' })),
getCurrentAppState: jest.fn(() => Promise.resolve({ app_state: 'active' })),
addListener: jest.fn(),
removeListeners: jest.fn(),
},
}));
// Mock core navigation to avoid requiring a NavigationContainer for hooks
jest.mock('@react-navigation/core', () => {
const actualCore = jest.requireActual('@react-navigation/core');
return {
...actualCore,
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
})),
};
});
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
// Note: Individual test files can override this with their own more specific mocks
jest.mock('react-native-webview', () => {
// Avoid requiring React to prevent nested require memory issues
// Return a simple pass-through mock - tests can override with JSX mocks if needed
const MockWebView = jest.fn(() => null);
MockWebView.displayName = 'MockWebView';
// Mock AppState to prevent getCurrentAppState errors
jest.mock('react-native/Libraries/AppState/AppState', () => {
// Use the global appStateListeners array so tests can access it
const appStateListeners = global.mockAppStateListeners || [];
return {
__esModule: true,
default: MockWebView,
WebView: MockWebView,
default: {
currentState: 'active',
addEventListener: jest.fn((eventType, handler) => {
appStateListeners.push(handler);
return {
remove: () => {
const index = appStateListeners.indexOf(handler);
if (index >= 0) {
appStateListeners.splice(index, 1);
}
},
};
}),
},
};
});
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
jest.mock('@/layouts/ExpandableBottomLayout', () => {
// Avoid requiring React to prevent nested require memory issues
// These need to pass through children so WebView is rendered
const Layout = ({ children, ...props }) => children;
const TopSection = ({ children, ...props }) => children;
const BottomSection = ({ children, ...props }) => children;
const FullSection = ({ children, ...props }) => children;
return {
__esModule: true,
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
};
});
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Button needs to render a host element with onPress so tests can interact with it
const Button = jest.fn(({ testID, icon, onPress, children, ...props }) => {
// Render as a mock-touchable-opacity host element so fireEvent.press works
// This allows tests to query by testID and press the button
return (
<mock-touchable-opacity testID={testID} onPress={onPress} {...props}>
{icon || children || null}
</mock-touchable-opacity>
);
});
Button.displayName = 'MockButton';
const XStack = jest.fn(({ children, ...props }) => children || null);
XStack.displayName = 'MockXStack';
const Text = jest.fn(({ children, ...props }) => children || null);
Text.displayName = 'MockText';
return {
__esModule: true,
Button,
XStack,
// Provide minimal Text to satisfy potential usages
Text,
};
});
// Mock Tamagui to avoid hermes-parser WASM memory issues during transformation
jest.mock('tamagui', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Helper to create a simple pass-through mock component
const createMockComponent = displayName => {
const Component = jest.fn(props => props.children || null);
Component.displayName = displayName;
return Component;
};
// Mock styled function - simplified version that returns the component
const styled = jest.fn(Component => Component);
// Create all Tamagui component mocks
const Button = createMockComponent('MockButton');
const XStack = createMockComponent('MockXStack');
const YStack = createMockComponent('MockYStack');
const ZStack = createMockComponent('MockZStack');
const Text = createMockComponent('MockText');
const View = createMockComponent('MockView');
const ScrollView = createMockComponent('MockScrollView');
const Spinner = createMockComponent('MockSpinner');
const Image = createMockComponent('MockImage');
const Card = createMockComponent('MockCard');
const Separator = createMockComponent('MockSeparator');
const TextArea = createMockComponent('MockTextArea');
const Input = createMockComponent('MockInput');
const Anchor = createMockComponent('MockAnchor');
// Mock Select component with nested components
const Select = Object.assign(createMockComponent('MockSelect'), {
Trigger: createMockComponent('MockSelectTrigger'),
Value: createMockComponent('MockSelectValue'),
Content: createMockComponent('MockSelectContent'),
Item: createMockComponent('MockSelectItem'),
Group: createMockComponent('MockSelectGroup'),
Label: createMockComponent('MockSelectLabel'),
Viewport: createMockComponent('MockSelectViewport'),
ScrollUpButton: createMockComponent('MockSelectScrollUpButton'),
ScrollDownButton: createMockComponent('MockSelectScrollDownButton'),
});
// Mock Sheet component with nested components
const Sheet = Object.assign(createMockComponent('MockSheet'), {
Frame: createMockComponent('MockSheetFrame'),
Overlay: createMockComponent('MockSheetOverlay'),
Handle: createMockComponent('MockSheetHandle'),
ScrollView: createMockComponent('MockSheetScrollView'),
});
// Mock Adapt component
const Adapt = createMockComponent('MockAdapt');
// Mock TamaguiProvider - simple pass-through that renders children
const TamaguiProvider = jest.fn(({ children }) => children || null);
TamaguiProvider.displayName = 'MockTamaguiProvider';
// Mock configuration factory functions
const createFont = jest.fn(() => ({}));
const createTamagui = jest.fn(() => ({}));
return {
__esModule: true,
styled,
Button,
XStack,
YStack,
ZStack,
Text,
View,
ScrollView,
Spinner,
Image,
Card,
Separator,
TextArea,
Input,
Anchor,
Select,
Sheet,
Adapt,
TamaguiProvider,
createFont,
createTamagui,
// Provide default exports for other common components
default: jest.fn(() => null),
};
});
// Mock Tamagui lucide icons to simple components to avoid theme context
jest.mock('@tamagui/lucide-icons', () => {
// Avoid requiring React to prevent nested require memory issues
// Return mock components that can be queried by testID
const makeIcon = name => {
// Use a mock element tag that React can render
const Icon = props => ({
$$typeof: Symbol.for('react.element'),
type: `mock-icon-${name}`,
props: { testID: `icon-${name}`, ...props },
key: null,
ref: null,
});
Icon.displayName = `MockIcon(${name})`;
return Icon;
};
return {
__esModule: true,
ExternalLink: makeIcon('external-link'),
X: makeIcon('x'),
Clipboard: makeIcon('clipboard'),
};
});
// Mock WebViewFooter to avoid SDK rendering complexity
jest.mock('@/components/WebViewFooter', () => {
// Avoid requiring React to prevent nested require memory issues
const WebViewFooter = jest.fn(() => null);
return { __esModule: true, WebViewFooter };
});

View File

@@ -35,6 +35,7 @@
"install-app:setup": "yarn install && yarn build:deps && yarn setup:android-deps && cd ios && bundle install && scripts/pod-install-with-cache-fix.sh && cd ..",
"ios": "yarn build:deps && node scripts/run-ios-simulator.cjs",
"ios:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose ios internal_test",
"jest:clear": "node ./node_modules/jest/bin/jest.js --clearCache",
"jest:run": "node ./node_modules/jest/bin/jest.js",
"lint": "eslint . --cache --cache-location .eslintcache",
"lint:fix": "eslint --fix . --cache --cache-location .eslintcache",

View File

@@ -0,0 +1,8 @@
// 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 RecoveryPromptAllowedRoute =
(typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number];
export const RECOVERY_PROMPT_ALLOWED_ROUTES = ['Home'] as const;

View File

@@ -3,10 +3,8 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useRef, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation';
import { navigationRef } from '@/navigation';
import type { ModalParams } from '@/screens/app/ModalScreen';
import {
getModalCallbacks,
@@ -16,23 +14,47 @@ import {
export const useModal = (params: ModalParams) => {
const [visible, setVisible] = useState(false);
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const callbackIdRef = useRef<number>();
const handleModalDismiss = useCallback(() => {
setVisible(false);
params.onModalDismiss();
}, [params]);
const handleModalButtonPress = useCallback(() => {
setVisible(false);
return params.onButtonPress();
}, [params]);
const showModal = useCallback(() => {
if (!navigationRef.isReady()) {
// Navigation not ready yet; avoid throwing and simply skip showing
return;
}
setVisible(true);
const { onButtonPress, onModalDismiss, ...rest } = params;
const id = registerModalCallbacks({ onButtonPress, onModalDismiss });
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onButtonPress: _ignored,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onModalDismiss: _ignored2,
...rest
} = params;
const id = registerModalCallbacks({
onButtonPress: handleModalButtonPress,
onModalDismiss: handleModalDismiss,
});
callbackIdRef.current = id;
navigation.navigate('Modal', { ...rest, callbackId: id });
}, [params, navigation]);
navigationRef.navigate('Modal', { ...rest, callbackId: id });
}, [handleModalButtonPress, handleModalDismiss, params]);
const dismissModal = useCallback(() => {
setVisible(false);
const routes = navigation.getState()?.routes;
if (!navigationRef.isReady()) {
return;
}
const routes = navigationRef.getState()?.routes;
if (routes?.at(routes.length - 1)?.name === 'Modal') {
navigation.goBack();
navigationRef.goBack();
}
if (callbackIdRef.current !== undefined) {
const callbacks = getModalCallbacks(callbackIdRef.current);
@@ -47,7 +69,7 @@ export const useModal = (params: ModalParams) => {
unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined;
}
}, [navigation]);
}, []);
return {
showModal,

View File

@@ -2,64 +2,167 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useEffect } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { AppStateStatus } from 'react-native';
import { AppState } from 'react-native';
import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts';
import { useModal } from '@/hooks/useModal';
import { navigationRef } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
// TODO: need to debug and test the logic. it pops up too often.
export default function useRecoveryPrompts() {
const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
const DEFAULT_ALLOWED_ROUTES = RECOVERY_PROMPT_ALLOWED_ROUTES;
type UseRecoveryPromptsOptions = {
allowedRoutes?: readonly string[];
};
export default function useRecoveryPrompts({
allowedRoutes = DEFAULT_ALLOWED_ROUTES,
}: UseRecoveryPromptsOptions = {}) {
const { homeScreenViewCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
useSettingStore();
const { getAllDocuments } = usePassport();
const hasRecoveryEnabled = cloudBackupEnabled || hasViewedRecoveryPhrase;
const { showModal, visible } = useModal({
titleText: 'Protect your account',
bodyText:
'Enable cloud backup or save your recovery phrase so you can recover your account.',
buttonText: 'Back up now',
onButtonPress: async () => {
if (navigationRef.isReady()) {
navigationRef.navigate('CloudBackupSettings', {
nextScreen: 'SaveRecoveryPhrase',
});
if (!navigationRef.isReady()) {
return;
}
navigationRef.navigate('CloudBackupSettings', {
nextScreen: 'SaveRecoveryPhrase',
});
},
onModalDismiss: () => {},
} as const);
useEffect(() => {
async function maybePrompt() {
if (!navigationRef.isReady()) {
const lastPromptCount = useRef<number | null>(null);
const appStateStatus = useRef<AppStateStatus>(
(AppState.currentState as AppStateStatus | null) ?? 'active',
);
const allowedRouteSet = useMemo(
() => new Set(allowedRoutes),
[allowedRoutes],
);
const isRouteEligible = useCallback(
(routeName: string | undefined): routeName is string => {
if (!routeName) {
return false;
}
if (!allowedRouteSet.has(routeName)) {
return false;
}
return true;
},
[allowedRouteSet],
);
const maybePrompt = useCallback(async () => {
if (!navigationRef.isReady()) {
return;
}
if (appStateStatus.current !== 'active') {
return;
}
const currentRouteName = navigationRef.getCurrentRoute?.()?.name;
if (!isRouteEligible(currentRouteName)) {
return;
}
if (hasRecoveryEnabled) {
return;
}
try {
const docs = await getAllDocuments();
const hasRegisteredDocument = Object.values(docs).some(
doc => doc.metadata.isRegistered === true,
);
if (!hasRegisteredDocument) {
return;
}
if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) {
try {
const docs = await getAllDocuments();
if (Object.keys(docs).length === 0) {
return;
}
const shouldPrompt =
loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0);
if (shouldPrompt) {
showModal();
}
} catch {
// Silently fail to avoid breaking the hook
// If we can't get documents, we shouldn't show the prompt
return;
const shouldPrompt =
homeScreenViewCount >= 5 && homeScreenViewCount % 5 === 0;
if (
shouldPrompt &&
!visible &&
lastPromptCount.current !== homeScreenViewCount
) {
// Double-check route eligibility right before showing modal
// to prevent showing on wrong screen if user navigated during async call
const currentRouteNameAfterAsync =
navigationRef.getCurrentRoute?.()?.name;
if (isRouteEligible(currentRouteNameAfterAsync)) {
showModal();
lastPromptCount.current = homeScreenViewCount;
}
}
} catch {
// Silently fail to avoid breaking the hook
// If we can't get documents, we shouldn't show the prompt
return;
}
maybePrompt().catch(() => {});
}, [
loginCount,
cloudBackupEnabled,
hasViewedRecoveryPhrase,
showModal,
getAllDocuments,
hasRecoveryEnabled,
homeScreenViewCount,
isRouteEligible,
showModal,
visible,
]);
useEffect(() => {
const runMaybePrompt = () => {
maybePrompt().catch(() => {
// Ignore promise rejection - already handled in maybePrompt
});
};
runMaybePrompt();
const handleAppStateChange = (nextState: AppStateStatus) => {
const previousState = appStateStatus.current;
appStateStatus.current = nextState;
if (
(previousState === 'background' || previousState === 'inactive') &&
nextState === 'active'
) {
runMaybePrompt();
}
};
const appStateSubscription = AppState.addEventListener(
'change',
handleAppStateChange,
);
const navigationUnsubscribe = navigationRef.addListener?.(
'state',
runMaybePrompt,
);
return () => {
appStateSubscription.remove();
if (typeof navigationUnsubscribe === 'function') {
navigationUnsubscribe();
}
};
}, [maybePrompt]);
return { visible };
}

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Buffer } from 'buffer';
import { Platform } from 'react-native';
import type { PassportData } from '@selfxyz/common/types';

View File

@@ -17,6 +17,7 @@ import type { DocumentCategory } from '@selfxyz/common/utils/types';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { DefaultNavBar } from '@/components/navbar';
import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
import AppLayout from '@/layouts/AppLayout';
import accountScreens from '@/navigation/account';
import appScreens from '@/navigation/app';
@@ -198,6 +199,7 @@ const { trackScreenView } = analytics();
const Navigation = createStaticNavigation(AppNavigation);
const NavigationWithTracking = () => {
useRecoveryPrompts();
const selfClient = useSelfClient();
const trackScreen = () => {
const currentRoute = navigationRef.getCurrentRoute();

View File

@@ -260,7 +260,6 @@ export const AuthProvider = ({
setIsAuthenticatingPromise(null);
setIsAuthenticated(true);
useSettingStore.getState().incrementLoginCount();
trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS);
setAuthenticatedTimeout(previousTimeout => {
if (previousTimeout) {

View File

@@ -65,32 +65,26 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
return;
}
// Dismiss the modal BEFORE calling the callback
// This prevents race conditions when the callback navigates to another screen
try {
// Try to execute the callback first
await callbacks.onButtonPress();
navigation.goBack();
unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error(
'Navigation error while dismissing modal:',
navigationError,
);
// Don't execute callback if modal couldn't be dismissed
return;
}
try {
// If callback succeeds, try to navigate back
navigation.goBack();
// Only unregister after successful navigation
unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error('Navigation error:', navigationError);
// Don't cleanup if navigation fails - modal might still be visible
}
// Now execute the callback (which may navigate to another screen)
// This only runs if dismissal succeeded
try {
await callbacks.onButtonPress();
} catch (callbackError) {
console.error('Callback error:', callbackError);
// If callback fails, we should still try to navigate and cleanup
try {
navigation.goBack();
unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error(
'Navigation error after callback failure:',
navigationError,
);
// Don't cleanup if navigation fails
}
}
}, [callbacks, navigation, params.callbackId]);
@@ -108,6 +102,9 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
padding={20}
borderRadius={10}
marginHorizontal={8}
width="79.5%"
maxWidth={460}
alignSelf="center"
>
<YStack gap={40}>
<XStack alignItems="center" justifyContent="space-between">

View File

@@ -47,6 +47,7 @@ import { useReferralConfirmation } from '@/hooks/useReferralConfirmation';
import { useTestReferralFlow } from '@/hooks/useTestReferralFlow';
import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import useUserStore from '@/stores/userStore';
const HomeScreen: React.FC = () => {
@@ -67,6 +68,7 @@ const HomeScreen: React.FC = () => {
Record<string, { data: IDDocument; metadata: DocumentMetadata }>
>({});
const [loading, setLoading] = useState(true);
const hasIncrementedOnFocus = useRef(false);
const { amount: selfPoints } = usePoints();
@@ -124,6 +126,21 @@ const HomeScreen: React.FC = () => {
}, [loadDocuments]),
);
useFocusEffect(
useCallback(() => {
if (hasIncrementedOnFocus.current) {
return;
}
hasIncrementedOnFocus.current = true;
useSettingStore.getState().incrementHomeScreenViewCount();
return () => {
hasIncrementedOnFocus.current = false;
};
}, []),
);
useFocusEffect(() => {
if (isNewVersionAvailable && !isModalDismissed) {
showAppUpdateModal();

View File

@@ -7,34 +7,34 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface PersistedSettingsState {
hasPrivacyNoteBeenDismissed: boolean;
dismissPrivacyNote: () => void;
addSubscribedTopic: (topic: string) => void;
biometricsAvailable: boolean;
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
cloudBackupEnabled: boolean;
toggleCloudBackupEnabled: () => void;
loginCount: number;
incrementLoginCount: () => void;
hasViewedRecoveryPhrase: boolean;
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
isDevMode: boolean;
setDevModeOn: () => void;
setDevModeOff: () => void;
hasCompletedKeychainMigration: boolean;
setKeychainMigrationCompleted: () => void;
dismissPrivacyNote: () => void;
fcmToken: string | null;
hasCompletedBackupForPoints: boolean;
hasCompletedKeychainMigration: boolean;
hasPrivacyNoteBeenDismissed: boolean;
hasViewedRecoveryPhrase: boolean;
homeScreenViewCount: number;
incrementHomeScreenViewCount: () => void;
isDevMode: boolean;
pointsAddress: string | null;
removeSubscribedTopic: (topic: string) => void;
resetBackupForPoints: () => void;
setBackupForPointsCompleted: () => void;
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
setDevModeOff: () => void;
setDevModeOn: () => void;
setFcmToken: (token: string | null) => void;
turnkeyBackupEnabled: boolean;
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
setKeychainMigrationCompleted: () => void;
setPointsAddress: (address: string | null) => void;
setSubscribedTopics: (topics: string[]) => void;
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
subscribedTopics: string[];
setSubscribedTopics: (topics: string[]) => void;
addSubscribedTopic: (topic: string) => void;
removeSubscribedTopic: (topic: string) => void;
hasCompletedBackupForPoints: boolean;
setBackupForPointsCompleted: () => void;
resetBackupForPoints: () => void;
pointsAddress: string | null;
setPointsAddress: (address: string | null) => void;
toggleCloudBackupEnabled: () => void;
turnkeyBackupEnabled: boolean;
}
interface NonPersistedSettingsState {
@@ -64,20 +64,33 @@ export const useSettingStore = create<SettingsState>()(
toggleCloudBackupEnabled: () =>
set(oldState => ({
cloudBackupEnabled: !oldState.cloudBackupEnabled,
loginCount: oldState.cloudBackupEnabled ? oldState.loginCount : 0,
homeScreenViewCount: oldState.cloudBackupEnabled
? oldState.homeScreenViewCount
: 0,
})),
loginCount: 0,
incrementLoginCount: () =>
set(oldState => ({ loginCount: oldState.loginCount + 1 })),
homeScreenViewCount: 0,
incrementHomeScreenViewCount: () =>
set(oldState => {
if (
oldState.cloudBackupEnabled ||
oldState.hasViewedRecoveryPhrase === true
) {
return oldState;
}
const nextCount = oldState.homeScreenViewCount + 1;
return {
homeScreenViewCount: nextCount >= 100 ? 0 : nextCount,
};
}),
hasViewedRecoveryPhrase: false,
setHasViewedRecoveryPhrase: viewed =>
set(oldState => ({
hasViewedRecoveryPhrase: viewed,
loginCount:
homeScreenViewCount:
viewed && !oldState.hasViewedRecoveryPhrase
? 0
: oldState.loginCount,
: oldState.homeScreenViewCount,
})),
isDevMode: false,

View File

@@ -0,0 +1,6 @@
// 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 for image files in Jest tests
module.exports = 'test-file-stub';

View File

@@ -0,0 +1,71 @@
// 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.
// Grouped navigation mocks to avoid cluttering jest.setup.js
jest.mock('@react-navigation/native', () => {
// Avoid nested requireActual to prevent OOM in CI
// Create mock navigator without requiring React
const MockNavigator = (props, _ref) => props.children;
MockNavigator.displayName = 'MockNavigator';
return {
useFocusEffect: jest.fn(callback => {
// Immediately invoke the effect for testing without requiring a container
return callback();
}),
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
})),
useRoute: jest.fn(() => ({
key: 'mock-route-key',
name: 'MockRoute',
params: {},
})),
useIsFocused: jest.fn(() => true),
useLinkTo: jest.fn(() => jest.fn()),
createNavigationContainerRef: jest.fn(() => global.mockNavigationRef),
createStaticNavigation: jest.fn(() => MockNavigator),
NavigationContainer: ({ children }) => children,
DefaultTheme: {},
DarkTheme: {},
};
});
jest.mock('@react-navigation/native-stack', () => ({
createNativeStackNavigator: jest.fn(config => config),
createNavigatorFactory: jest.fn(),
}));
// Mock core navigation to avoid requiring a NavigationContainer for hooks
jest.mock('@react-navigation/core', () => {
// Avoid nested requireActual to prevent OOM in CI
return {
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
})),
useRoute: jest.fn(() => ({
key: 'mock-route-key',
name: 'MockRoute',
params: {},
})),
useIsFocused: jest.fn(() => true),
useLinkTo: jest.fn(() => jest.fn()),
NavigationContext: {
Provider: ({ children }) => children,
Consumer: ({ children }) => children(null),
},
NavigationRouteContext: {
Provider: ({ children }) => children,
Consumer: ({ children }) => children(null),
},
};
});

View File

@@ -0,0 +1,199 @@
// 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.
// UI-related mocks grouped to keep jest.setup.js concise
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
// Note: Individual test files can override this with their own more specific mocks
jest.mock('react-native-webview', () => {
// Avoid requiring React to prevent nested require memory issues
// Return a simple pass-through mock - tests can override with JSX mocks if needed
const MockWebView = jest.fn(() => null);
MockWebView.displayName = 'MockWebView';
return {
__esModule: true,
default: MockWebView,
WebView: MockWebView,
};
});
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
jest.mock('@/layouts/ExpandableBottomLayout', () => {
// Avoid requiring React to prevent nested require memory issues
// These need to pass through children so WebView is rendered
const Layout = ({ children, ...props }) => children;
const TopSection = ({ children, ...props }) => children;
const BottomSection = ({ children, ...props }) => children;
const FullSection = ({ children, ...props }) => children;
return {
__esModule: true,
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
};
});
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Button needs to render a host element with onPress so tests can interact with it
const Button = jest.fn(({ testID, icon, onPress, children, ...props }) => {
// Render as a mock-touchable-opacity host element so fireEvent.press works
// This allows tests to query by testID and press the button
return (
<mock-touchable-opacity testID={testID} onPress={onPress} {...props}>
{icon || children || null}
</mock-touchable-opacity>
);
});
Button.displayName = 'MockButton';
const XStack = jest.fn(({ children, ...props }) => children || null);
XStack.displayName = 'MockXStack';
const Text = jest.fn(({ children, ...props }) => children || null);
Text.displayName = 'MockText';
return {
__esModule: true,
Button,
XStack,
// Provide minimal Text to satisfy potential usages
Text,
};
});
// Mock Tamagui to avoid hermes-parser WASM memory issues during transformation
jest.mock('tamagui', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Helper to create a simple pass-through mock component
const createMockComponent = displayName => {
const Component = jest.fn(props => props.children || null);
Component.displayName = displayName;
return Component;
};
// Mock styled function - simplified version that returns the component
const styled = jest.fn(Component => Component);
// Create all Tamagui component mocks
const Button = createMockComponent('MockButton');
const XStack = createMockComponent('MockXStack');
const YStack = createMockComponent('MockYStack');
const ZStack = createMockComponent('MockZStack');
const Text = createMockComponent('MockText');
const View = createMockComponent('MockView');
const ScrollView = createMockComponent('MockScrollView');
const Spinner = createMockComponent('MockSpinner');
const Image = createMockComponent('MockImage');
const Card = createMockComponent('MockCard');
const Separator = createMockComponent('MockSeparator');
const TextArea = createMockComponent('MockTextArea');
const Input = createMockComponent('MockInput');
const Anchor = createMockComponent('MockAnchor');
// Mock Select component with nested components
const Select = Object.assign(createMockComponent('MockSelect'), {
Trigger: createMockComponent('MockSelectTrigger'),
Value: createMockComponent('MockSelectValue'),
Content: createMockComponent('MockSelectContent'),
Item: createMockComponent('MockSelectItem'),
Group: createMockComponent('MockSelectGroup'),
Label: createMockComponent('MockSelectLabel'),
Viewport: createMockComponent('MockSelectViewport'),
ScrollUpButton: createMockComponent('MockSelectScrollUpButton'),
ScrollDownButton: createMockComponent('MockSelectScrollDownButton'),
});
// Mock Sheet component with nested components
const Sheet = Object.assign(createMockComponent('MockSheet'), {
Frame: createMockComponent('MockSheetFrame'),
Overlay: createMockComponent('MockSheetOverlay'),
Handle: createMockComponent('MockSheetHandle'),
ScrollView: createMockComponent('MockSheetScrollView'),
});
// Mock Adapt component
const Adapt = createMockComponent('MockAdapt');
// Mock TamaguiProvider - simple pass-through that renders children
const TamaguiProvider = jest.fn(({ children }) => children || null);
TamaguiProvider.displayName = 'MockTamaguiProvider';
// Mock configuration factory functions
const createFont = jest.fn(() => ({}));
const createTamagui = jest.fn(() => ({}));
return {
__esModule: true,
styled,
Button,
XStack,
YStack,
ZStack,
Text,
View,
ScrollView,
Spinner,
Image,
Card,
Separator,
TextArea,
Input,
Anchor,
Select,
Sheet,
Adapt,
TamaguiProvider,
createFont,
createTamagui,
// Provide default exports for other common components
default: jest.fn(() => null),
};
});
// Mock Tamagui lucide icons to simple components to avoid theme context
jest.mock('@tamagui/lucide-icons', () => {
// Avoid requiring React to prevent nested require memory issues
// Return mock components that can be queried by testID
const makeIcon = name => {
// Use a mock element tag that React can render
const Icon = props => ({
$$typeof: Symbol.for('react.element'),
type: `mock-icon-${name}`,
props: { testID: `icon-${name}`, ...props },
key: null,
ref: null,
});
Icon.displayName = `MockIcon(${name})`;
return Icon;
};
return {
__esModule: true,
ExternalLink: makeIcon('external-link'),
X: makeIcon('x'),
Clipboard: makeIcon('clipboard'),
};
});
// Mock WebViewFooter to avoid SDK rendering complexity
jest.mock('@/components/WebViewFooter', () => {
// Avoid requiring React to prevent nested require memory issues
const WebViewFooter = jest.fn(() => null);
return { __esModule: true, WebViewFooter };
});
// Mock screens that use mobile-sdk-alpha flows with PixelRatio issues or missing dependencies
jest.mock('@/screens/documents/selection/ConfirmBelongingScreen', () => {
const MockScreen = jest.fn(() => null);
MockScreen.displayName = 'MockConfirmBelongingScreen';
return { __esModule: true, default: MockScreen };
});
jest.mock('@/screens/documents/selection/CountryPickerScreen', () => {
const MockScreen = jest.fn(() => null);
MockScreen.displayName = 'MockCountryPickerScreen';
return { __esModule: true, default: MockScreen };
});

View File

@@ -2,32 +2,22 @@
// 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 { act, renderHook } from '@testing-library/react-native';
import { useModal } from '@/hooks/useModal';
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
const mockGetState = jest.fn(() => ({
routes: [{ name: 'Home' }, { name: 'Modal' }],
}));
describe('useModal', () => {
beforeEach(() => {
(useNavigation as jest.Mock).mockReturnValue({
navigate: mockNavigate,
goBack: mockGoBack,
getState: mockGetState,
// Reset all mocks including the global navigationRef
jest.clearAllMocks();
// Set up the navigation ref mock with proper methods
global.mockNavigationRef.isReady.mockReturnValue(true);
global.mockNavigationRef.getState.mockReturnValue({
routes: [{ name: 'Home' }, { name: 'Modal' }],
index: 1,
});
mockNavigate.mockClear();
mockGoBack.mockClear();
mockGetState.mockClear();
});
it('should navigate to Modal with callbackId and handle dismissal', () => {
@@ -45,8 +35,10 @@ describe('useModal', () => {
act(() => result.current.showModal());
expect(mockNavigate).toHaveBeenCalledTimes(1);
const params = mockNavigate.mock.calls[0][1];
expect(global.mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
const [screenName, params] =
global.mockNavigationRef.navigate.mock.calls[0];
expect(screenName).toBe('Modal');
expect(params).toMatchObject({
titleText: 'Title',
bodyText: 'Body',
@@ -58,7 +50,7 @@ describe('useModal', () => {
act(() => result.current.dismissModal());
expect(mockGoBack).toHaveBeenCalled();
expect(global.mockNavigationRef.goBack).toHaveBeenCalled();
expect(onModalDismiss).toHaveBeenCalled();
expect(getModalCallbacks(id)).toBeUndefined();
});

View File

@@ -2,6 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { AppState } from 'react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useModal } from '@/hooks/useModal';
@@ -9,56 +10,175 @@ import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
const navigationStateListeners: Array<() => void> = [];
let isNavigationReady = true;
// Use global appStateListeners from jest.setup.js mock
const appStateListeners = global.mockAppStateListeners || [];
jest.mock('@/hooks/useModal');
jest.mock('@/providers/passportDataProvider');
jest.mock('@/navigation', () => ({
navigationRef: {
isReady: jest.fn(() => true),
navigate: jest.fn(),
},
navigationRef: global.mockNavigationRef,
}));
// Use global react-native mock from jest.setup.js - no need to mock here
const showModal = jest.fn();
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
const getAllDocuments = jest.fn();
(usePassport as jest.Mock).mockReturnValue({ getAllDocuments });
const getAppState = (): {
currentState: string;
addEventListener: jest.Mock;
} =>
AppState as unknown as {
currentState: string;
addEventListener: jest.Mock;
};
describe('useRecoveryPrompts', () => {
beforeEach(() => {
showModal.mockClear();
getAllDocuments.mockResolvedValue({ doc1: {} as any });
jest.clearAllMocks();
navigationStateListeners.length = 0;
appStateListeners.length = 0;
isNavigationReady = true;
// Setup the global navigation ref mock
global.mockNavigationRef.isReady.mockImplementation(
() => isNavigationReady,
);
global.mockNavigationRef.getCurrentRoute.mockReturnValue({ name: 'Home' });
global.mockNavigationRef.addListener.mockImplementation(
(_: string, callback: () => void) => {
navigationStateListeners.push(callback);
return () => {
const index = navigationStateListeners.indexOf(callback);
if (index >= 0) {
navigationStateListeners.splice(index, 1);
}
};
},
);
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
getAllDocuments.mockResolvedValue({
doc1: {
data: {} as any,
metadata: { isRegistered: true } as any,
},
});
const mockAppState = getAppState();
mockAppState.currentState = 'active';
act(() => {
useSettingStore.setState({
loginCount: 0,
homeScreenViewCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('shows modal on first login', async () => {
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
it('does not show modal before the fifth home view', async () => {
for (const count of [1, 2, 3, 4]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
}
});
it('does not show modal when login count is 4', async () => {
it('waits for navigation readiness before prompting', async () => {
isNavigationReady = false;
global.mockNavigationRef.isReady.mockImplementation(
() => isNavigationReady,
);
act(() => {
useSettingStore.setState({ loginCount: 4 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
isNavigationReady = true;
navigationStateListeners.forEach(listener => listener());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('shows modal on eighth login', async () => {
it('respects custom allow list overrides', async () => {
act(() => {
useSettingStore.setState({ loginCount: 8 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
showModal.mockClear();
global.mockNavigationRef.getCurrentRoute.mockReturnValue({
name: 'Settings',
});
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('prompts when returning from background on eligible route', async () => {
// This test verifies that the hook registers an app state listener
// and that the prompt logic can be triggered multiple times for different view counts
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
const { rerender, unmount } = renderHook(() => useRecoveryPrompts());
// Wait for initial prompt
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
// Clear and test with a different login count that should trigger again
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: 10 }); // next multiple of 5
});
rerender();
// Wait for second prompt with new login count
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
unmount();
});
it('does not show modal for non-multiple-of-five view counts', async () => {
for (const count of [6, 7, 8, 9]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
}
});
it('shows modal on fifth home view', async () => {
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -68,7 +188,10 @@ describe('useRecoveryPrompts', () => {
it('does not show modal if backup already enabled', async () => {
act(() => {
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
useSettingStore.setState({
homeScreenViewCount: 5,
cloudBackupEnabled: true,
});
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -76,12 +199,8 @@ describe('useRecoveryPrompts', () => {
});
});
it('does not show modal when navigation is not ready', async () => {
const navigationRef = require('@/navigation').navigationRef;
navigationRef.isReady.mockReturnValueOnce(false);
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
it('does not show modal if already visible', async () => {
(useModal as jest.Mock).mockReturnValueOnce({ showModal, visible: true });
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
@@ -91,7 +210,7 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when recovery phrase has been viewed', async () => {
act(() => {
useSettingStore.setState({
loginCount: 1,
homeScreenViewCount: 5,
hasViewedRecoveryPhrase: true,
});
});
@@ -104,7 +223,7 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when no documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({});
act(() => {
useSettingStore.setState({ loginCount: 1 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -112,11 +231,51 @@ describe('useRecoveryPrompts', () => {
});
});
it('shows modal for other valid login counts', async () => {
for (const count of [2, 3, 13, 18]) {
it('does not show modal when only unregistered documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({
doc1: {
data: {} as any,
metadata: { isRegistered: false } as any,
},
doc2: {
data: {} as any,
metadata: { isRegistered: undefined } as any,
},
});
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});
it('shows modal when registered documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({
doc1: {
data: {} as any,
metadata: { isRegistered: false } as any,
},
doc2: {
data: {} as any,
metadata: { isRegistered: true } as any,
},
});
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('shows modal for other valid view counts (multiples of five)', async () => {
for (const count of [5, 10, 15]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ loginCount: count });
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -125,6 +284,32 @@ describe('useRecoveryPrompts', () => {
}
});
it('does not show modal again for same login count when state changes', async () => {
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
showModal.mockClear();
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: false });
});
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});
it('returns correct visible state', () => {
const { result } = renderHook(() => useRecoveryPrompts());
expect(result.current.visible).toBe(false);
@@ -134,8 +319,9 @@ describe('useRecoveryPrompts', () => {
renderHook(() => useRecoveryPrompts());
expect(useModal).toHaveBeenCalledWith({
titleText: 'Protect your account',
bodyText:
bodyText: expect.stringContaining(
'Enable cloud backup or save your recovery phrase so you can recover your account.',
),
buttonText: 'Back up now',
onButtonPress: expect.any(Function),
onModalDismiss: expect.any(Function),

View File

@@ -0,0 +1,107 @@
// 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 { render } from '@testing-library/react-native';
jest.mock('@/hooks/useRecoveryPrompts', () => jest.fn());
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
useSelfClient: jest.fn(() => ({})),
}));
jest.mock('@/navigation/deeplinks', () => ({
setupUniversalLinkListenerInNavigation: jest.fn(() => jest.fn()),
}));
jest.mock('@/services/analytics', () => ({
__esModule: true,
default: jest.fn(() => ({
trackEvent: jest.fn(),
trackScreenView: jest.fn(),
flush: jest.fn(),
})),
}));
describe('navigation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should have the correct navigation screens', () => {
// Unmock @/navigation for this test to get the real navigationScreens
jest.unmock('@/navigation');
jest.isolateModules(() => {
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',
'Loading',
'ManageDocuments',
'MockDataDeepLink',
'Modal',
'Points',
'PointsInfo',
'ProofHistory',
'ProofHistoryDetail',
'ProofRequestStatus',
'Prove',
'QRCodeTrouble',
'QRCodeViewFinder',
'RecoverWithPhrase',
'Referral',
'SaveRecoveryPhrase',
'Settings',
'ShowRecoveryPhrase',
'Splash',
'WebView',
]);
});
});
it('wires recovery prompts hook into navigation', () => {
// Temporarily restore the React mock and unmock @/navigation for this test
jest.unmock('@/navigation');
const useRecoveryPrompts =
require('@/hooks/useRecoveryPrompts') as jest.Mock;
// Since we're testing the wiring and not the actual rendering,
// we can just check if the module exports the default component
// and verify the hook is called when the component is imported
const navigation = require('@/navigation');
expect(navigation.default).toBeDefined();
// Render the component to trigger the hooks
const NavigationWithTracking = navigation.default;
render(<NavigationWithTracking />);
expect(useRecoveryPrompts).toHaveBeenCalledWith();
});
});

View File

@@ -1,157 +0,0 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { act } from '@testing-library/react-native';
import { useSettingStore } from '@/stores/settingStore';
describe('settingStore', () => {
beforeEach(() => {
act(() => {
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('increments login count', () => {
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
});
it('increments login count multiple times', () => {
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(3);
});
it('increments login count from non-zero initial value', () => {
act(() => {
useSettingStore.setState({ loginCount: 5 });
});
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(6);
});
it('resets login count when recovery phrase viewed', () => {
act(() => {
useSettingStore.setState({ loginCount: 2 });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when setting recovery phrase viewed to false', () => {
act(() => {
useSettingStore.setState({
loginCount: 3,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
});
it('resets login count when enabling cloud backup', () => {
act(() => {
useSettingStore.setState({ loginCount: 3, cloudBackupEnabled: false });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when disabling cloud backup', () => {
act(() => {
useSettingStore.setState({ loginCount: 4, cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(4);
});
it('handles sequential actions that reset login count', () => {
// Increment login count
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(2);
// Toggle cloud backup (should reset to 0)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
// Increment again
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
// Set recovery phrase viewed (should reset to 0)
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when setting recovery phrase viewed to true when already true', () => {
act(() => {
useSettingStore.setState({
loginCount: 5,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(5);
});
it('handles complex sequence of mixed operations', () => {
// Start with some increments
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(3);
// Disable cloud backup (should not reset)
act(() => {
useSettingStore.setState({ cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
// Set recovery phrase viewed to false (should not reset)
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
// Enable cloud backup (should reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('maintains login count when toggling cloud backup from true to false then back to true', () => {
// Start with cloud backup enabled and some login count
act(() => {
useSettingStore.setState({ loginCount: 2, cloudBackupEnabled: true });
});
// Toggle to disable (should not reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(2);
// Toggle to enable (should reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
});

View File

@@ -1,4 +1,5 @@
import forge from 'node-forge';
import { Buffer } from 'buffer';
import { WS_DB_RELAYER, WS_DB_RELAYER_STAGING } from '../constants/index.js';
import { initElliptic } from '../utils/certificate_parsing/elliptic.js';