diff --git a/app/src/components/Mnemonic.tsx b/app/src/components/Mnemonic.tsx index 31e5461fd..aae3a3795 100644 --- a/app/src/components/Mnemonic.tsx +++ b/app/src/components/Mnemonic.tsx @@ -4,6 +4,7 @@ import Clipboard from '@react-native-clipboard/clipboard'; import React, { useCallback, useState } from 'react'; import { Button, Text, XStack, YStack } from 'tamagui'; +import { useSettingStore } from '../stores/settingStore'; import { black, slate50, @@ -50,11 +51,13 @@ const REDACTED = new Array(24) const Mnemonic = ({ words = REDACTED, onRevealWords }: MnemonicProps) => { const [revealWords, setRevealWords] = useState(false); const [copied, setCopied] = useState(false); + const { setHasViewedRecoveryPhrase } = useSettingStore(); const copyToClipboardOrReveal = useCallback(async () => { confirmTap(); if (!revealWords) { // TODO: container jumps when words are revealed on android await onRevealWords?.(); + setHasViewedRecoveryPhrase(true); return setRevealWords(previous => !previous); } Clipboard.setString(words.join(' ')); diff --git a/app/src/hooks/useRecoveryPrompts.ts b/app/src/hooks/useRecoveryPrompts.ts new file mode 100644 index 000000000..0f7cc88d5 --- /dev/null +++ b/app/src/hooks/useRecoveryPrompts.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 +import { useEffect } from 'react'; + +import { navigationRef } from '../navigation'; +import { useSettingStore } from '../stores/settingStore'; +import { useModal } from './useModal'; + +export default function useRecoveryPrompts() { + const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } = + useSettingStore(); + 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', + }); + } + }, + onModalDismiss: () => {}, + } as const); + + useEffect(() => { + if (!navigationRef.isReady()) { + return; + } + if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) { + const shouldPrompt = + loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0); + if (shouldPrompt) { + showModal(); + } + } + }, [loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase, showModal]); + + return { visible }; +} diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 054609e28..052bfd498 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -13,6 +13,7 @@ import ReactNativeBiometrics from 'react-native-biometrics'; import Keychain from 'react-native-keychain'; import { AuthEvents } from '../consts/analytics'; +import { useSettingStore } from '../stores/settingStore'; import { Mnemonic } from '../types/mnemonic'; import analytics from '../utils/analytics'; @@ -208,6 +209,7 @@ export const AuthProvider = ({ setIsAuthenticatingPromise(null); setIsAuthenticated(true); + useSettingStore.getState().incrementLoginCount(); trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS); setAuthenticatedTimeout(previousTimeout => { if (previousTimeout) { diff --git a/app/src/screens/home/HomeScreen.tsx b/app/src/screens/home/HomeScreen.tsx index 94d2b13ee..955b2e4dd 100644 --- a/app/src/screens/home/HomeScreen.tsx +++ b/app/src/screens/home/HomeScreen.tsx @@ -11,6 +11,7 @@ import { Caption } from '../../components/typography/Caption'; import { useAppUpdates } from '../../hooks/useAppUpdates'; import useConnectionModal from '../../hooks/useConnectionModal'; import useHapticNavigation from '../../hooks/useHapticNavigation'; +import useRecoveryPrompts from '../../hooks/useRecoveryPrompts'; import SelfCard from '../../images/card-style-1.svg'; import ScanIcon from '../../images/icons/qr_scan.svg'; import WarnIcon from '../../images/icons/warning.svg'; @@ -36,6 +37,7 @@ const ScanButton = styled(Button, { const HomeScreen: React.FC = () => { useConnectionModal(); + useRecoveryPrompts(); const [isNewVersionAvailable, showAppUpdateModal, isModalDismissed] = useAppUpdates(); diff --git a/app/src/stores/proofHistoryStore.ts b/app/src/stores/proofHistoryStore.ts index f4c40de77..b3dfd9d8d 100644 --- a/app/src/stores/proofHistoryStore.ts +++ b/app/src/stores/proofHistoryStore.ts @@ -54,10 +54,21 @@ const PAGE_SIZE = 20; const DB_NAME = Platform.OS === 'ios' ? 'proof_history.db' : 'proof_history.db'; const TABLE_NAME = 'proof_history'; const STALE_PROOF_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const SYNC_THROTTLE_MS = 30 * 1000; // 30 seconds throttle for sync calls export const useProofHistoryStore = create()((set, get) => { + let lastSyncTime = 0; // Track last sync time for throttling + const syncProofHistoryStatus = async () => { try { + // Throttling mechanism - prevent sync if called too frequently + const now = Date.now(); + if (now - lastSyncTime < SYNC_THROTTLE_MS) { + console.log('Sync throttled - too soon since last sync'); + return; + } + lastSyncTime = now; + set({ isLoading: true }); const db = await SQLite.openDatabase({ name: DB_NAME, @@ -69,10 +80,32 @@ export const useProofHistoryStore = create()((set, get) => { `SELECT sessionId FROM ${TABLE_NAME} WHERE status = ? AND timestamp <= ?`, [ProofStatus.PENDING, tenMinutesAgo], ); + + // Improved error handling - wrap each updateProofStatus call in try-catch + let successfulUpdates = 0; + let failedUpdates = 0; + for (let i = 0; i < stalePending.rows.length; i++) { const { sessionId } = stalePending.rows.item(i); - await get().updateProofStatus(sessionId, ProofStatus.FAILURE); + try { + await get().updateProofStatus(sessionId, ProofStatus.FAILURE); + successfulUpdates++; + } catch (error) { + console.error( + `Failed to update proof status for session ${sessionId}:`, + error, + ); + failedUpdates++; + // Continue with the next iteration instead of stopping the entire loop + } } + + if (stalePending.rows.length > 0) { + console.log( + `Stale proof cleanup: ${successfulUpdates} successful, ${failedUpdates} failed`, + ); + } + const [pendingProofs] = await db.executeSql(` SELECT * FROM ${TABLE_NAME} WHERE status = '${ProofStatus.PENDING}' `); diff --git a/app/src/stores/settingStore.ts b/app/src/stores/settingStore.ts index 1cf8cb366..4cd51fdcf 100644 --- a/app/src/stores/settingStore.ts +++ b/app/src/stores/settingStore.ts @@ -11,6 +11,10 @@ interface PersistedSettingsState { setBiometricsAvailable: (biometricsAvailable: boolean) => void; cloudBackupEnabled: boolean; toggleCloudBackupEnabled: () => void; + loginCount: number; + incrementLoginCount: () => void; + hasViewedRecoveryPhrase: boolean; + setHasViewedRecoveryPhrase: (viewed: boolean) => void; isDevMode: boolean; setDevModeOn: () => void; setDevModeOff: () => void; @@ -43,6 +47,20 @@ export const useSettingStore = create()( toggleCloudBackupEnabled: () => set(oldState => ({ cloudBackupEnabled: !oldState.cloudBackupEnabled, + loginCount: oldState.cloudBackupEnabled ? oldState.loginCount : 0, + })), + + loginCount: 0, + incrementLoginCount: () => + set(oldState => ({ loginCount: oldState.loginCount + 1 })), + hasViewedRecoveryPhrase: false, + setHasViewedRecoveryPhrase: viewed => + set(oldState => ({ + hasViewedRecoveryPhrase: viewed, + loginCount: + viewed && !oldState.hasViewedRecoveryPhrase + ? 0 + : oldState.loginCount, })), isDevMode: false, diff --git a/app/tests/src/hooks/useRecoveryPrompts.test.ts b/app/tests/src/hooks/useRecoveryPrompts.test.ts new file mode 100644 index 000000000..7e95ac5e6 --- /dev/null +++ b/app/tests/src/hooks/useRecoveryPrompts.test.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 +import { renderHook } from '@testing-library/react-native'; + +import { useModal } from '../../../src/hooks/useModal'; +import useRecoveryPrompts from '../../../src/hooks/useRecoveryPrompts'; +import { useSettingStore } from '../../../src/stores/settingStore'; + +jest.mock('../../../src/hooks/useModal'); +jest.mock('../../../src/navigation', () => ({ + navigationRef: { + isReady: jest.fn(() => true), + navigate: jest.fn(), + }, +})); + +const showModal = jest.fn(); +(useModal as jest.Mock).mockReturnValue({ showModal, visible: false }); + +describe('useRecoveryPrompts', () => { + beforeEach(() => { + showModal.mockClear(); + useSettingStore.setState({ + loginCount: 0, + cloudBackupEnabled: false, + hasViewedRecoveryPhrase: false, + }); + }); + + it('shows modal on first login', () => { + useSettingStore.setState({ loginCount: 1 }); + renderHook(() => useRecoveryPrompts()); + expect(showModal).toHaveBeenCalled(); + }); + + it('does not show modal when login count is 4', () => { + useSettingStore.setState({ loginCount: 4 }); + renderHook(() => useRecoveryPrompts()); + expect(showModal).not.toHaveBeenCalled(); + }); + + it('shows modal on eighth login', () => { + useSettingStore.setState({ loginCount: 8 }); + renderHook(() => useRecoveryPrompts()); + expect(showModal).toHaveBeenCalled(); + }); + + it('does not show modal if backup already enabled', () => { + useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true }); + renderHook(() => useRecoveryPrompts()); + expect(showModal).not.toHaveBeenCalled(); + }); + + it('does not show modal when navigation is not ready', () => { + const navigationRef = require('../../../src/navigation').navigationRef; + navigationRef.isReady.mockReturnValueOnce(false); + useSettingStore.setState({ loginCount: 1 }); + renderHook(() => useRecoveryPrompts()); + expect(showModal).not.toHaveBeenCalled(); + }); + + it('does not show modal when recovery phrase has been viewed', () => { + useSettingStore.setState({ loginCount: 1, hasViewedRecoveryPhrase: true }); + renderHook(() => useRecoveryPrompts()); + expect(showModal).not.toHaveBeenCalled(); + }); + + it('shows modal for other valid login counts', () => { + [2, 3, 13, 18].forEach(count => { + showModal.mockClear(); + useSettingStore.setState({ loginCount: count }); + renderHook(() => useRecoveryPrompts()); + expect(showModal).toHaveBeenCalled(); + }); + }); + + it('returns correct visible state', () => { + const { result } = renderHook(() => useRecoveryPrompts()); + expect(result.current.visible).toBe(false); + }); + + it('calls useModal with correct parameters', () => { + renderHook(() => useRecoveryPrompts()); + expect(useModal).toHaveBeenCalledWith({ + titleText: 'Protect your account', + bodyText: + '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), + }); + }); +}); diff --git a/app/tests/src/stores/settingStore.test.ts b/app/tests/src/stores/settingStore.test.ts new file mode 100644 index 000000000..ad0819415 --- /dev/null +++ b/app/tests/src/stores/settingStore.test.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 +import { useSettingStore } from '../../../src/stores/settingStore'; + +describe('settingStore', () => { + beforeEach(() => { + 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', () => { + useSettingStore.setState({ loginCount: 5 }); + useSettingStore.getState().incrementLoginCount(); + expect(useSettingStore.getState().loginCount).toBe(6); + }); + + it('resets login count when recovery phrase viewed', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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) + 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) + 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 + 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); + }); +});