diff --git a/eslint.config.mjs b/eslint.config.mjs index cb9fb95b68b..0f2c658b594 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -81,14 +81,12 @@ export default [ 'src/actions/FioAddressActions.ts', 'src/actions/FirstOpenActions.tsx', 'src/actions/LoanWelcomeActions.tsx', - 'src/actions/LocalSettingsActions.ts', - 'src/actions/LoginActions.tsx', 'src/actions/NotificationActions.ts', 'src/actions/PaymentProtoActions.tsx', 'src/actions/ReceiveDropdown.tsx', 'src/actions/RecoveryReminderActions.tsx', - 'src/actions/RequestReviewActions.tsx', + 'src/actions/ScamWarningActions.tsx', 'src/actions/ScanActions.tsx', @@ -290,7 +288,7 @@ export default [ 'src/components/scenes/OtpSettingsScene.tsx', 'src/components/scenes/PasswordRecoveryScene.tsx', 'src/components/scenes/PromotionSettingsScene.tsx', - 'src/components/scenes/ReviewTriggerTestScene.tsx', + 'src/components/scenes/SecurityAlertsScene.tsx', 'src/components/scenes/SettingsScene.tsx', @@ -311,7 +309,7 @@ export default [ 'src/components/scenes/TransactionListScene.tsx', 'src/components/scenes/TransactionsExportScene.tsx', 'src/components/scenes/UpgradeUsernameScreen.tsx', - 'src/components/scenes/WalletListScene.tsx', + 'src/components/scenes/WalletRestoreScene.tsx', 'src/components/scenes/WcConnectionsScene.tsx', 'src/components/scenes/WcConnectScene.tsx', diff --git a/src/__tests__/actions/RequestReviewActions.test.ts b/src/__tests__/actions/RequestReviewActions.test.ts index f8563ff8898..c03957027ce 100644 --- a/src/__tests__/actions/RequestReviewActions.test.ts +++ b/src/__tests__/actions/RequestReviewActions.test.ts @@ -3,7 +3,11 @@ import { makeMemoryDisklet } from 'disklet' import type { EdgeAccount } from 'edge-core-js' import type { Action, Dispatch } from 'redux' -import { LOCAL_SETTINGS_FILENAME } from '../../actions/LocalSettingsActions' +import { + LOCAL_SETTINGS_FILENAME, + LOCAL_SETTINGS_FILENAME_OPTIMIZED, + resetLocalAccountSettingsCache +} from '../../actions/LocalSettingsActions' import { DEPOSIT_AMOUNT_THRESHOLD, FIAT_PURCHASE_COUNT_THRESHOLD, @@ -102,6 +106,8 @@ jest.mock('react-native-in-app-review', () => ({ describe('RequestReviewActions', () => { beforeEach(async () => { + // Reset the settings cache to ensure fresh reads for each test + resetLocalAccountSettingsCache() // Create a fresh disklet for each test to avoid data persistence between tests mockDisklet = makeMemoryDisklet() mockAccount = { @@ -321,7 +327,10 @@ describe('RequestReviewActions', () => { expect(readData.swapCount).toBe(7) // Also verify that the data was written to settings file - const settingsJson = await testDisklet.getText(LOCAL_SETTINGS_FILENAME) + // Note: writeLocalAccountSettings writes to the optimized filename + const settingsJson = await testDisklet.getText( + LOCAL_SETTINGS_FILENAME_OPTIMIZED + ) const settings = JSON.parse(settingsJson) as LocalAccountSettings expect(settings.reviewTrigger?.swapCount).toBe(7) }) diff --git a/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap b/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap index b3985e66a10..cb7f9be4384 100644 --- a/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap +++ b/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap @@ -112,6 +112,7 @@ exports[`initialState 1`] = ` "defaultFiat": "USD", "defaultIsoFiat": "iso:USD", "denominationSettings": {}, + "denominationSettingsOptimized": false, "developerModeOn": false, "isAccountBalanceVisible": true, "isTouchEnabled": false, diff --git a/src/actions/LocalSettingsActions.ts b/src/actions/LocalSettingsActions.ts index b7b9ac2e199..254a4610304 100644 --- a/src/actions/LocalSettingsActions.ts +++ b/src/actions/LocalSettingsActions.ts @@ -16,6 +16,9 @@ import { import { logActivity } from '../util/logger' export const LOCAL_SETTINGS_FILENAME = 'Settings.json' +// Used for performance testing - allows A/B comparison between builds without +// corrupting original settings files. Remove after performance testing complete. +export const LOCAL_SETTINGS_FILENAME_OPTIMIZED = 'Settings-optimized.json' let localAccountSettings: LocalAccountSettings = asLocalAccountSettings({}) const [watchAccountSettings, emitAccountSettings] = @@ -24,16 +27,32 @@ watchAccountSettings(s => { localAccountSettings = s }) -let readSettingsFromDisk = false +// Use a promise to ensure only one disk read happens, even with concurrent callers +let readSettingsPromise: Promise | null = null + export const getLocalAccountSettings = async ( account: EdgeAccount ): Promise => { - if (readSettingsFromDisk) return localAccountSettings - const settings = await readLocalAccountSettings(account) - return settings + // If we already have the settings cached, return them immediately + if (readSettingsPromise != null) { + return await readSettingsPromise + } + + // Start reading and cache the promise so concurrent callers wait on the same read + readSettingsPromise = readLocalAccountSettings(account) + return await readSettingsPromise } -export function useAccountSettings() { +/** + * Reset the local settings cache. Call this on logout so the next login + * reads fresh settings from disk. + */ +export const resetLocalAccountSettingsCache = (): void => { + readSettingsPromise = null + localAccountSettings = asLocalAccountSettings({}) +} + +export function useAccountSettings(): LocalAccountSettings { const [accountSettings, setAccountSettings] = React.useState(localAccountSettings) React.useEffect(() => watchAccountSettings(setAccountSettings), []) @@ -261,16 +280,31 @@ export const writeTokenWarningsShown = async ( export const readLocalAccountSettings = async ( account: EdgeAccount ): Promise => { + // Try optimized file first (for performance testing), then fall back to original + try { + const text = await account.localDisklet.getText( + LOCAL_SETTINGS_FILENAME_OPTIMIZED + ) + const json = JSON.parse(text) + const settings = asLocalAccountSettings(json) + emitAccountSettings(settings) + return settings + } catch (error: unknown) { + // Optimized file doesn't exist, try original file + } + try { const text = await account.localDisklet.getText(LOCAL_SETTINGS_FILENAME) const json = JSON.parse(text) const settings = asLocalAccountSettings(json) emitAccountSettings(settings) - readSettingsFromDisk = true return settings - } catch (e) { + } catch (error: unknown) { + // If Settings.json doesn't exist yet, return defaults without writing. + // Defaults can be derived from cleaners. Only write when values change. const defaults = asLocalAccountSettings({}) - return await writeLocalAccountSettings(account, defaults) + emitAccountSettings(defaults) + return defaults } } @@ -281,8 +315,12 @@ export const writeLocalAccountSettings = async ( // Refresh cache, notify callers emitAccountSettings(settings) + // Update the cached promise so future reads return the new settings + readSettingsPromise = Promise.resolve(settings) + const text = JSON.stringify(settings) - await account.localDisklet.setText(LOCAL_SETTINGS_FILENAME, text) + // Write to optimized filename for performance testing + await account.localDisklet.setText(LOCAL_SETTINGS_FILENAME_OPTIMIZED, text) return settings } diff --git a/src/actions/LoginActions.tsx b/src/actions/LoginActions.tsx index 033ee4f2fdd..676bee2b1d8 100644 --- a/src/actions/LoginActions.tsx +++ b/src/actions/LoginActions.tsx @@ -12,7 +12,10 @@ import { getCurrencies } from 'react-native-localize' import performance from 'react-native-performance' import { sprintf } from 'sprintf-js' -import { readSyncedSettings } from '../actions/SettingsActions' +import { + migrateDenominationSettings, + readSyncedSettings +} from '../actions/SettingsActions' import { ConfirmContinueModal } from '../components/modals/ConfirmContinueModal' import { FioCreateHandleModal } from '../components/modals/FioCreateHandleModal' import { SurveyModal } from '../components/modals/SurveyModal' @@ -26,7 +29,7 @@ import { } from '../reducers/scenes/SettingsReducer' import type { WalletCreateItem } from '../selectors/getCreateWalletList' import { config } from '../theme/appConfig' -import type { Dispatch, ThunkAction } from '../types/reduxTypes' +import type { Dispatch, GetState, ThunkAction } from '../types/reduxTypes' import type { EdgeAppSceneProps, NavigationBase } from '../types/routerTypes' import { currencyCodesToEdgeAssets } from '../util/CurrencyInfoHelpers' import { logActivity } from '../util/logger' @@ -41,16 +44,24 @@ import { getDeviceSettings, writeIsSurveyDiscoverShown } from './DeviceSettingsActions' -import { readLocalAccountSettings } from './LocalSettingsActions' +import { + getLocalAccountSettings, + resetLocalAccountSettingsCache +} from './LocalSettingsActions' import { registerNotificationsV2, updateNotificationSettings } from './NotificationActions' -import { showScamWarningModal } from './ScamWarningActions' const PER_WALLET_TIMEOUT = 5000 const MIN_CREATE_WALLET_TIMEOUT = 20000 +type SyncedSettings = ReturnType extends Promise< + infer T +> + ? T + : never + function getFirstActiveWalletInfo(account: EdgeAccount): { walletId: string currencyCode: string @@ -78,280 +89,381 @@ export function initializeAccount( account: EdgeAccount ): ThunkAction> { return async (dispatch, getState) => { - const rootNavigation = getRootNavigation(navigation) - - // Log in as quickly as possible, but we do need the sort order: - const syncedSettings = await readSyncedSettings(account) - const { walletsSort } = syncedSettings - dispatch({ type: 'LOGIN', data: { account, walletSort: walletsSort } }) const { newAccount } = account - const referralPromise = dispatch(loadAccountReferral(account)) + const rootNavigation = getRootNavigation(navigation) - // Track whether we showed a non-survey modal or some other interrupting UX. - // We don't want to pester the user with too many interrupting flows. - let hideSurvey = false + // Step 1: Common early initialization + const { syncedSettings, referralPromise } = await initializeAccountInner( + account, + dispatch + ) + // Step 2: Account-type specific navigation and setup if (newAccount) { - await referralPromise - let { defaultFiat } = syncedSettings + await navigateToNewAccountFlow( + rootNavigation, + account, + syncedSettings, + referralPromise, + dispatch, + getState + ) + } else { + navigateToExistingAccountHome(rootNavigation, referralPromise) + } - const [phoneCurrency] = getCurrencies() - if (typeof phoneCurrency === 'string' && phoneCurrency.length >= 3) { - defaultFiat = phoneCurrency - } - // Ensure the creation reason is available before creating wallets: - const accountReferralCurrencyCodes = - getState().account.accountReferral.currencyCodes - const defaultSelection = - accountReferralCurrencyCodes != null - ? currencyCodesToEdgeAssets(account, accountReferralCurrencyCodes) - : config.defaultWallets - const fiatCurrencyCode = 'iso:' + defaultFiat - - // Ensure we have initialized the account settings first so we can begin - // keeping track of token warnings shown from the initial selected assets - // during account creation - await readLocalAccountSettings(account) - - const newAccountFlow = async ( - navigation: EdgeAppSceneProps< - 'createWalletSelectCrypto' | 'createWalletSelectCryptoNewAccount' - >['navigation'], - items: WalletCreateItem[] - ) => { - navigation.replace('edgeTabs', { screen: 'home' }) - const createWalletsPromise = createCustomWallets( - account, - fiatCurrencyCode, - items, - dispatch - ).catch(error => { - showError(error) - }) + // Step 3: Common post-navigation initialization + const walletInfo = newAccount + ? undefined + : getFirstActiveWalletInfo(account) + const hideSurvey = await finalizeAccountInit( + navigation, + account, + newAccount, + syncedSettings, + walletInfo, + dispatch, + getState + ) - // New user FIO handle registration flow (if env is properly configured) - const { freeRegApiToken = '', freeRegRefCode = '' } = - typeof ENV.FIO_INIT === 'object' ? ENV.FIO_INIT : {} - if (freeRegApiToken !== '' && freeRegRefCode !== '') { - hideSurvey = true - const isCreateHandle = await Airship.show(bridge => ( - - )) - if (isCreateHandle) { - navigation.navigate('fioCreateHandle', { - freeRegApiToken, - freeRegRefCode - }) - } - } + // Step 4: Survey modal (existing accounts only) + if ( + !newAccount && + !hideSurvey && + !getDeviceSettings().isSurveyDiscoverShown && + config.disableSurveyModal !== true + ) { + await Airship.show(bridge => ) + await writeIsSurveyDiscoverShown(true) + } + } +} - await createWalletsPromise - dispatch( - logEvent('Signup_Complete', { - numAccounts: getState().core.context.localUsers.length - }) - ) - } +/** + * Step 1: Common early initialization - reads settings and dispatches LOGIN. + */ +async function initializeAccountInner( + account: EdgeAccount, + dispatch: Dispatch +): Promise<{ + syncedSettings: SyncedSettings + referralPromise: Promise +}> { + // Log in as quickly as possible, but we do need the sort order: + const syncedSettings = await readSyncedSettings(account) + const { walletsSort } = syncedSettings + dispatch({ type: 'LOGIN', data: { account, walletSort: walletsSort } }) + const referralPromise = dispatch(loadAccountReferral(account)) + + return { syncedSettings, referralPromise } +} - rootNavigation.replace('edgeApp', { - screen: 'edgeAppStack', - params: { - screen: 'createWalletSelectCryptoNewAccount', - params: { - newAccountFlow, - defaultSelection, - disableLegacy: true - } - } - }) +/** + * Step 2a: Navigate to wallet creation flow for new accounts. + */ +async function navigateToNewAccountFlow( + rootNavigation: NavigationBase, + account: EdgeAccount, + syncedSettings: SyncedSettings, + referralPromise: Promise, + dispatch: Dispatch, + getState: GetState +): Promise { + await referralPromise + let { defaultFiat } = syncedSettings - performance.mark('loginEnd', { detail: { isNewAccount: newAccount } }) - } else { - const { defaultScreen } = getDeviceSettings() - rootNavigation.replace('edgeApp', { - screen: 'edgeAppStack', - params: { - screen: 'edgeTabs', - params: - defaultScreen === 'home' - ? { screen: 'home' } - : { screen: 'walletsTab', params: { screen: 'walletList' } } - } - }) - referralPromise.catch(() => { - console.log(`Failed to load account referral info`) - }) + const [phoneCurrency] = getCurrencies() + if (typeof phoneCurrency === 'string' && phoneCurrency.length >= 3) { + defaultFiat = phoneCurrency + } - performance.mark('loginEnd', { detail: { isNewAccount: newAccount } }) - } + // Ensure the creation reason is available before creating wallets: + const accountReferralCurrencyCodes = + getState().account.accountReferral.currencyCodes + const defaultSelection = + accountReferralCurrencyCodes != null + ? currencyCodesToEdgeAssets(account, accountReferralCurrencyCodes) + : config.defaultWallets + const fiatCurrencyCode = 'iso:' + defaultFiat + + // Ensure we have initialized the account settings first so we can begin + // keeping track of token warnings shown from the initial selected assets + // during account creation + await getLocalAccountSettings(account) + + const newAccountFlow = async ( + navigation: EdgeAppSceneProps< + 'createWalletSelectCrypto' | 'createWalletSelectCryptoNewAccount' + >['navigation'], + items: WalletCreateItem[] + ): Promise => { + navigation.replace('edgeTabs', { screen: 'home' }) + const createWalletsPromise = createCustomWallets( + account, + fiatCurrencyCode, + items, + dispatch + ).catch((error: unknown) => { + showError(error) + }) - // Show a notice for deprecated electrum server settings - const pluginIdsNeedingUserAction: string[] = [] - for (const pluginId in account.currencyConfig) { - const currencyConfig = account.currencyConfig[pluginId] - const { userSettings } = currencyConfig - if (userSettings == null) continue - if ( - userSettings.disableFetchingServers === true && - userSettings.enableCustomServers == null - ) { - userSettings.enableCustomServers = true - userSettings.blockbookServers = [] - userSettings.electrumServers = [] - pluginIdsNeedingUserAction.push(pluginId) - } - } - if (pluginIdsNeedingUserAction.length > 0) { - hideSurvey = true - await Airship.show(bridge => ( - (bridge => ( + )) - .finally(() => { - for (const pluginId of pluginIdsNeedingUserAction) { - const currencyConfig = account.currencyConfig[pluginId] - const { userSettings = {} } = currencyConfig - currencyConfig - .changeUserSettings(userSettings) - .catch((error: unknown) => { - showError(error) - }) - } - }) - .catch(err => { - showError(err) + if (isCreateHandle) { + navigation.navigate('fioCreateHandle', { + freeRegApiToken, + freeRegRefCode }) + } } - // Show the scam warning modal if needed - if (await showScamWarningModal('firstLogin')) hideSurvey = true + await createWalletsPromise + dispatch( + logEvent('Signup_Complete', { + numAccounts: getState().core.context.localUsers.length + }) + ) + } - // Check for security alerts: - if (hasSecurityAlerts(account)) { - navigation.push('securityAlerts') - hideSurvey = true + rootNavigation.replace('edgeApp', { + screen: 'edgeAppStack', + params: { + screen: 'createWalletSelectCryptoNewAccount', + params: { + newAccountFlow, + defaultSelection, + disableLegacy: true + } } + }) +} - const state = getState() - const { context } = state.core - - // Sign up for push notifications: - dispatch(registerNotificationsV2()).catch(e => { - console.error(e) - }) +/** + * Step 2b: Navigate to home screen for existing accounts. + */ +function navigateToExistingAccountHome( + rootNavigation: NavigationBase, + referralPromise: Promise +): void { + const { defaultScreen } = getDeviceSettings() + rootNavigation.replace('edgeApp', { + screen: 'edgeAppStack', + params: { + screen: 'edgeTabs', + params: + defaultScreen === 'home' + ? { screen: 'home' } + : { screen: 'walletsTab', params: { screen: 'walletList' } } + } + }) + referralPromise.catch(() => { + console.log(`Failed to load account referral info`) + }) +} - const walletInfos = account.allKeys - const filteredWalletInfos = walletInfos.map(({ keys, id, ...info }) => info) - console.log('Wallet Infos:', filteredWalletInfos) +/** + * Step 3: Common post-navigation initialization. + * Returns true if survey should be hidden. + */ +async function finalizeAccountInit( + navigation: NavigationBase, + account: EdgeAccount, + newAccount: boolean, + syncedSettings: SyncedSettings, + walletInfo: { walletId: string; currencyCode: string } | undefined, + dispatch: Dispatch, + getState: GetState +): Promise { + let hideSurvey = false + + performance.mark('loginEnd', { detail: { isNewAccount: newAccount } }) + + // Show a notice for deprecated electrum server settings + hideSurvey = await showDeprecatedElectrumNotice(account, hideSurvey) + + // Check for security alerts: + if (hasSecurityAlerts(account)) { + navigation.push('securityAlerts') + hideSurvey = true + } - // Merge and prepare settings files: - let accountInitObject: AccountInitPayload = { - ...initialState, - account, - currencyCode: '', - pinLoginEnabled: false, - isTouchEnabled: await isTouchEnabled(account), - isTouchSupported: (await getSupportedBiometryType()) !== false, - walletId: '', - walletsSort: 'manual' - } - try { - if (!newAccount) { - // We have a wallet - const { walletId, currencyCode } = getFirstActiveWalletInfo(account) - accountInitObject.walletId = walletId - accountInitObject.currencyCode = currencyCode - } + // Sign up for push notifications: + dispatch(registerNotificationsV2()).catch((e: unknown) => { + console.error(e) + }) - accountInitObject = { ...accountInitObject, ...syncedSettings } + const walletInfos = account.allKeys + const filteredWalletInfos = walletInfos.map(({ keys, id, ...info }) => info) + console.log('Wallet Infos:', filteredWalletInfos) - const loadedLocalSettings = await readLocalAccountSettings(account) - accountInitObject = { ...accountInitObject, ...loadedLocalSettings } + const showedReminder = await completeAccountInit( + account, + syncedSettings, + dispatch, + getState, + walletInfo + ) + if (showedReminder) { + hideSurvey = true + } - for (const userInfo of context.localUsers) { - if ( - userInfo.loginId === account.rootLoginId && - userInfo.pinLoginEnabled - ) { - accountInitObject.pinLoginEnabled = true - } - } + return hideSurvey +} - const defaultDenominationSettings = state.ui.settings.denominationSettings - const syncedDenominationSettings = - syncedSettings?.denominationSettings ?? {} - const mergedDenominationSettings = {} - - for (const plugin of Object.keys(defaultDenominationSettings)) { - // @ts-expect-error - mergedDenominationSettings[plugin] = {} - // @ts-expect-error - for (const code of Object.keys(defaultDenominationSettings[plugin])) { - // @ts-expect-error - mergedDenominationSettings[plugin][code] = { - // @ts-expect-error - ...defaultDenominationSettings[plugin][code], - ...(syncedDenominationSettings?.[plugin]?.[code] ?? {}) - } - } - } - accountInitObject.denominationSettings = { ...mergedDenominationSettings } +/** + * Completes account initialization by loading settings, dispatching + * ACCOUNT_INIT_COMPLETE, and showing notification permission reminder. + * Returns true if the notification permission reminder was shown. + */ +async function completeAccountInit( + account: EdgeAccount, + syncedSettings: SyncedSettings, + dispatch: Dispatch, + getState: GetState, + walletInfo?: { walletId: string; currencyCode: string } +): Promise { + const { context } = getState().core + + // Merge and prepare settings files: + let accountInitObject: AccountInitPayload = { + ...initialState, + account, + currencyCode: walletInfo?.currencyCode ?? '', + pinLoginEnabled: false, + walletId: walletInfo?.walletId ?? '', + walletsSort: 'manual' + } + // Load biometric state in background (non-blocking) + Promise.all([isTouchEnabled(account), getSupportedBiometryType()]) + .then(([touchEnabled, supportedType]) => { dispatch({ - type: 'ACCOUNT_INIT_COMPLETE', - data: { ...accountInitObject } + type: 'UI/SETTINGS/SET_TOUCH_ID_SUPPORT', + data: { + isTouchEnabled: touchEnabled, + isTouchSupported: supportedType !== false + } }) + }) + .catch(() => { + // Fail silently - biometric state will remain at defaults + }) - await dispatch(refreshAccountReferral()) + let showedReminder = false + try { + accountInitObject = { ...accountInitObject, ...syncedSettings } - refreshTouchId(account).catch(() => { - // We have always failed silently here - }) + const loadedLocalSettings = await getLocalAccountSettings(account) + accountInitObject = { ...accountInitObject, ...loadedLocalSettings } + + for (const userInfo of context.localUsers) { if ( - await showNotificationPermissionReminder({ - appName: config.appName, - onLogEvent(event, values) { - dispatch(logEvent(event, values)) - }, - onNotificationPermit(info) { - dispatch(updateNotificationSettings(info.notificationOptIns)).catch( - error => { - trackError(error, 'LoginScene:onLogin:setDeviceSettings') - console.error(error) - } - ) - } - }) + userInfo.loginId === account.rootLoginId && + userInfo.pinLoginEnabled ) { - hideSurvey = true + accountInitObject.pinLoginEnabled = true } - } catch (error: any) { - showError(error) } - // Post login stuff: + // Use synced denomination settings directly (user customizations only). + // Default denominations are derived on-demand from currencyInfo via selectors. + accountInitObject.denominationSettings = + syncedSettings?.denominationSettings ?? {} + + dispatch({ + type: 'ACCOUNT_INIT_COMPLETE', + data: { ...accountInitObject } + }) + + // Run one-time migration to clean up denomination settings in background + migrateDenominationSettings(account, syncedSettings).catch( + (error: unknown) => { + console.log('Failed to migrate denomination settings:', error) + } + ) + + await dispatch(refreshAccountReferral()) + + refreshTouchId(account).catch(() => { + // We have always failed silently here + }) + + showedReminder = await showNotificationPermissionReminder({ + appName: config.appName, + onLogEvent(event, values) { + dispatch(logEvent(event, values)) + }, + onNotificationPermit(info) { + dispatch(updateNotificationSettings(info.notificationOptIns)).catch( + (error: unknown) => { + trackError(error, 'LoginScene:onLogin:setDeviceSettings') + console.error(error) + } + ) + } + }) + } catch (error: unknown) { + showError(error) + } + + return showedReminder +} + +async function showDeprecatedElectrumNotice( + account: EdgeAccount, + hideSurvey: boolean +): Promise { + const pluginIdsNeedingUserAction: string[] = [] + for (const pluginId in account.currencyConfig) { + const currencyConfig = account.currencyConfig[pluginId] + const { userSettings } = currencyConfig + if (userSettings == null) continue if ( - !newAccount && - !hideSurvey && - !getDeviceSettings().isSurveyDiscoverShown && - config.disableSurveyModal !== true + userSettings.disableFetchingServers === true && + userSettings.enableCustomServers == null ) { - // Show the survey modal once per app install, only if this isn't the - // first login of a newly created account and the user didn't get any - // other modals or scene changes immediately after login. - await Airship.show(bridge => ) - await writeIsSurveyDiscoverShown(true) + userSettings.enableCustomServers = true + userSettings.blockbookServers = [] + userSettings.electrumServers = [] + pluginIdsNeedingUserAction.push(pluginId) } } + if (pluginIdsNeedingUserAction.length > 0) { + hideSurvey = true + await Airship.show(bridge => ( + + )) + .finally(() => { + for (const pluginId of pluginIdsNeedingUserAction) { + const currencyConfig = account.currencyConfig[pluginId] + const { userSettings = {} } = currencyConfig + currencyConfig + .changeUserSettings(userSettings) + .catch((err: unknown) => { + showError(err) + }) + } + }) + .catch((err: unknown) => { + showError(err) + }) + } + return hideSurvey } export function getRootNavigation(navigation: NavigationBase): NavigationBase { @@ -375,6 +487,7 @@ export function logoutRequest( const { account } = state.core Keyboard.dismiss() Airship.clear() + resetLocalAccountSettingsCache() dispatch({ type: 'LOGOUT' }) if (typeof account.logout === 'function') await account.logout() const rootNavigation = getRootNavigation(navigation) @@ -430,7 +543,7 @@ async function createCustomWallets( account.createCurrencyWallets(options), timeoutMs, new Error(lstrings.error_creating_wallets) - ).catch(error => { + ).catch((error: unknown) => { dispatch(logEvent('Signup_Wallets_Created_Failed', { error })) throw error }) diff --git a/src/actions/RequestReviewActions.tsx b/src/actions/RequestReviewActions.tsx index 9abe150f622..89cf1199d1a 100644 --- a/src/actions/RequestReviewActions.tsx +++ b/src/actions/RequestReviewActions.tsx @@ -16,7 +16,7 @@ import { type ReviewTriggerData } from '../types/types' import { - readLocalAccountSettings, + getLocalAccountSettings, writeLocalAccountSettings } from './LocalSettingsActions' @@ -95,8 +95,8 @@ export const readReviewTriggerData = async ( account: EdgeAccount ): Promise => { try { - // Get settings from account - const settings = await readLocalAccountSettings(account) + // Get settings from account (uses cached promise to avoid duplicate disk reads) + const settings = await getLocalAccountSettings(account) // If review trigger data exists in settings, use it if (settings.reviewTrigger != null) { @@ -112,14 +112,15 @@ export const readReviewTriggerData = async ( const swapCountData = JSON.parse(swapCountDataStr) // Initialize new data structure with old swap count data + const parsedSwapCount = parseInt(swapCountData.swapCount) const migratedData: ReviewTriggerData = { ...initReviewTriggerData(), - swapCount: parseInt(swapCountData.swapCount) || 0 + swapCount: Number.isNaN(parsedSwapCount) ? 0 : parsedSwapCount } // If user was already asked for review in the old system, // set nextTriggerDate to 1 year in the future - if (swapCountData.hasReviewBeenRequested) { + if (swapCountData.hasReviewBeenRequested === true) { const nextYear = new Date() nextYear.setFullYear(nextYear.getFullYear() + 1) migratedData.nextTriggerDate = nextYear @@ -438,7 +439,7 @@ export const writeReviewTriggerData = async ( account: EdgeAccount, reviewTriggerData: Partial ): Promise => { - const settings = await readLocalAccountSettings(account) + const settings = await getLocalAccountSettings(account) const updatedSettings: LocalAccountSettings = { ...settings, diff --git a/src/actions/SettingsActions.tsx b/src/actions/SettingsActions.tsx index 21f4ae9425c..ab53297013e 100644 --- a/src/actions/SettingsActions.tsx +++ b/src/actions/SettingsActions.tsx @@ -438,6 +438,8 @@ export const asSyncedAccountSettings = asObject({ asDenominationSettings, () => ({}) ), + // Flag to track one-time denomination settings cleanup migration + denominationSettingsOptimized: asMaybe(asBoolean, false), securityCheckedWallets: asMaybe( asSecurityCheckedWallets, () => ({}) @@ -451,6 +453,9 @@ export type SyncedAccountSettings = ReturnType export const SYNCED_ACCOUNT_DEFAULTS = asSyncedAccountSettings({}) const SYNCED_SETTINGS_FILENAME = 'Settings.json' +// Used for performance testing - allows A/B comparison between builds without +// corrupting original settings files. Remove after performance testing complete. +const SYNCED_SETTINGS_FILENAME_OPTIMIZED = 'Settings-optimized.json' // Account Settings const writeAutoLogoutTimeInSeconds = async ( @@ -560,15 +565,26 @@ const writeDenominationKeySetting = async ( export async function readSyncedSettings( account: EdgeAccount ): Promise { + if (account?.disklet?.getText == null) return SYNCED_ACCOUNT_DEFAULTS + + // Try optimized file first (for performance testing), then fall back to original + try { + const text = await account.disklet.getText( + SYNCED_SETTINGS_FILENAME_OPTIMIZED + ) + const settingsFromFile = JSON.parse(text) + return asSyncedAccountSettings(settingsFromFile) + } catch (error: unknown) { + // Optimized file doesn't exist, try original file + } + try { - if (account?.disklet?.getText == null) return SYNCED_ACCOUNT_DEFAULTS const text = await account.disklet.getText(SYNCED_SETTINGS_FILENAME) const settingsFromFile = JSON.parse(text) return asSyncedAccountSettings(settingsFromFile) - } catch (e: any) { - console.log(e) - // If Settings.json doesn't exist yet, create it, and return it - await writeSyncedSettings(account, SYNCED_ACCOUNT_DEFAULTS) + } catch (error: unknown) { + // If Settings.json doesn't exist yet, return defaults without writing. + // Defaults can be derived from cleaners. Only write when values change. return SYNCED_ACCOUNT_DEFAULTS } } @@ -579,7 +595,8 @@ export async function writeSyncedSettings( ): Promise { const text = JSON.stringify(settings) if (account?.disklet?.setText == null) return - await account.disklet.setText(SYNCED_SETTINGS_FILENAME, text) + // Write to optimized filename for performance testing + await account.disklet.setText(SYNCED_SETTINGS_FILENAME_OPTIMIZED, text) } const updateCurrencySettings = ( @@ -596,3 +613,95 @@ const updateCurrencySettings = ( updatedSettings.denominationSettings[pluginId][currencyCode] = denomination return updatedSettings } + +/** + * One-time migration to clean up denomination settings by removing entries + * that match the default values from currencyInfo. This reduces the size of + * the synced settings file and speeds up subsequent logins. + * + * Only runs once per account - tracked via denominationSettingsOptimized flag. + */ +export async function migrateDenominationSettings( + account: EdgeAccount, + syncedSettings: SyncedAccountSettings +): Promise { + const { denominationSettings, denominationSettingsOptimized } = syncedSettings + + // Already migrated or no settings to clean + if (denominationSettingsOptimized) return + if ( + denominationSettings == null || + Object.keys(denominationSettings).length === 0 + ) { + // No denomination settings to clean, just set the flag + await writeSyncedSettings(account, { + ...syncedSettings, + denominationSettingsOptimized: true + }) + return + } + + // Clean up denomination settings by removing entries that match defaults + const cleanedSettings: DenominationSettings = {} + let needsCleanup = false + + for (const pluginId of Object.keys(denominationSettings)) { + const currencyConfig = account.currencyConfig[pluginId] + if (currencyConfig == null) continue + + const { currencyInfo, allTokens } = currencyConfig + const pluginDenoms = denominationSettings[pluginId] + if (pluginDenoms == null) continue + + cleanedSettings[pluginId] = {} + + for (const currencyCode of Object.keys(pluginDenoms)) { + const savedDenom = pluginDenoms[currencyCode] + if (savedDenom == null) continue + + // Find the default denomination for this currency + let defaultDenom: EdgeDenomination | undefined + if (currencyCode === currencyInfo.currencyCode) { + defaultDenom = currencyInfo.denominations[0] + } else { + // Look for token + for (const tokenId of Object.keys(allTokens)) { + const token = allTokens[tokenId] + if (token.currencyCode === currencyCode) { + defaultDenom = token.denominations[0] + break + } + } + } + + // Only keep if different from default + if ( + defaultDenom == null || + savedDenom.multiplier !== defaultDenom.multiplier || + savedDenom.name !== defaultDenom.name + ) { + // @ts-expect-error - DenominationSettings type allows undefined + cleanedSettings[pluginId][currencyCode] = savedDenom + } else { + needsCleanup = true + } + } + + // Remove empty plugin entries + if (Object.keys(cleanedSettings[pluginId] ?? {}).length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete cleanedSettings[pluginId] + } + } + + // Write cleaned settings with optimization flag + await writeSyncedSettings(account, { + ...syncedSettings, + denominationSettings: cleanedSettings, + denominationSettingsOptimized: true + }) + + if (needsCleanup) { + console.log('Denomination settings cleaned up - removed default values') + } +} diff --git a/src/actions/WalletListMenuActions.tsx b/src/actions/WalletListMenuActions.tsx index a705c294847..5dd611b7315 100644 --- a/src/actions/WalletListMenuActions.tsx +++ b/src/actions/WalletListMenuActions.tsx @@ -27,7 +27,6 @@ import { logActivity } from '../util/logger' import { validatePassword } from './AccountActions' import { showDeleteWalletModal } from './DeleteWalletModalActions' import { showResyncWalletModal } from './ResyncWalletModalActions' -import { showScamWarningModal } from './ScamWarningActions' import { toggleUserPausedWallet } from './SettingsActions' export type WalletListMenuKey = @@ -208,9 +207,6 @@ export function walletListMenuAction( const wallet = account.currencyWallets[walletId] const { xpubExplorer } = wallet.currencyInfo - // Show the scam warning modal if needed - await showScamWarningModal('firstPrivateKeyView') - const displayPublicSeed = await account.getDisplayPublicKey(wallet.id) const copy: ButtonInfo = { @@ -283,9 +279,6 @@ export function walletListMenuAction( const { currencyWallets } = account const wallet = currencyWallets[walletId] - // Show the scam warning modal if needed - await showScamWarningModal('firstPrivateKeyView') - const passwordValid = (await dispatch( validatePassword({ diff --git a/src/components/scenes/ReviewTriggerTestScene.tsx b/src/components/scenes/ReviewTriggerTestScene.tsx index 00f2f50a322..4aa8e928445 100644 --- a/src/components/scenes/ReviewTriggerTestScene.tsx +++ b/src/components/scenes/ReviewTriggerTestScene.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { ScrollView, View } from 'react-native' import { - readLocalAccountSettings, + getLocalAccountSettings, writeLocalAccountSettings } from '../../actions/LocalSettingsActions' import { @@ -37,7 +37,7 @@ import { EdgeText } from '../themed/EdgeText' interface Props extends EdgeSceneProps<'reviewTriggerTest'> {} -export const ReviewTriggerTestScene = (props: Props) => { +export const ReviewTriggerTestScene = (props: Props): React.ReactElement => { const dispatch = useDispatch() const theme = useTheme() const styles = getStyles(theme) @@ -54,7 +54,7 @@ export const ReviewTriggerTestScene = (props: Props) => { // Function to refresh review trigger data const refreshReviewData = useHandler(async () => { try { - const settings = await readLocalAccountSettings(account) + const settings = await getLocalAccountSettings(account) setReviewData(settings.reviewTrigger) } catch (error) { console.log('Error reading review trigger data:', JSON.stringify(error)) @@ -154,7 +154,7 @@ export const ReviewTriggerTestScene = (props: Props) => { const handleResetReviewData = useHandler(async () => { try { // Get the current account settings - const settings = await readLocalAccountSettings(account) + const settings = await getLocalAccountSettings(account) // Remove review trigger data if it exists if (settings.reviewTrigger != null) { @@ -191,7 +191,7 @@ export const ReviewTriggerTestScene = (props: Props) => { // In a real app, we would use a function to properly update this // We're directly manipulating the data rather than using an action for demonstration - const settings = await readLocalAccountSettings(account) + const settings = await getLocalAccountSettings(account) if (settings.reviewTrigger == null) { showToast('No review trigger data found') return diff --git a/src/components/scenes/WalletListScene.tsx b/src/components/scenes/WalletListScene.tsx index d994355aaf2..8c0cf06d59f 100644 --- a/src/components/scenes/WalletListScene.tsx +++ b/src/components/scenes/WalletListScene.tsx @@ -38,7 +38,7 @@ import { WalletListSwipeable } from '../themed/WalletListSwipeable' interface Props extends WalletsTabSceneProps<'walletList'> {} -export function WalletListScene(props: Props) { +export function WalletListScene(props: Props): React.JSX.Element { const { navigation } = props const theme = useTheme() const styles = getStyles(theme) @@ -77,7 +77,7 @@ export function WalletListScene(props: Props) { setSorting(true) } }) - .catch(error => { + .catch((error: unknown) => { showError(error) }) }) diff --git a/src/components/scenes/WcConnectionsScene.tsx b/src/components/scenes/WcConnectionsScene.tsx index bccad41f3aa..b4178d4762f 100644 --- a/src/components/scenes/WcConnectionsScene.tsx +++ b/src/components/scenes/WcConnectionsScene.tsx @@ -8,7 +8,6 @@ import AntDesignIcon from 'react-native-vector-icons/AntDesign' import { sprintf } from 'sprintf-js' import { checkAndShowLightBackupModal } from '../../actions/BackupModalActions' -import { showScamWarningModal } from '../../actions/ScamWarningActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { SPECIAL_CURRENCY_INFO } from '../../constants/WalletAndCurrencyConstants' import { useAsyncEffect } from '../../hooks/useAsyncEffect' @@ -125,9 +124,6 @@ export const WcConnectionsScene = (props: Props) => { } const handleNewConnectionPress = async () => { - // Show the scam warning modal if needed - await showScamWarningModal('firstWalletConnect') - if (checkAndShowLightBackupModal(account, navigation as NavigationBase)) { await Promise.resolve() } else { diff --git a/src/reducers/scenes/SettingsReducer.ts b/src/reducers/scenes/SettingsReducer.ts index 9bebd1b204d..5243eb64d98 100644 --- a/src/reducers/scenes/SettingsReducer.ts +++ b/src/reducers/scenes/SettingsReducer.ts @@ -53,26 +53,10 @@ export const settingsLegacy = ( ): SettingsState => { switch (action.type) { case 'LOGIN': { - const { account, walletSort } = action.data - - // Setup default denominations for settings based on currencyInfo - const newState = { ...state, walletSort } - for (const pluginId of Object.keys(account.currencyConfig)) { - const { currencyInfo } = account.currencyConfig[pluginId] - const { currencyCode } = currencyInfo - if (newState.denominationSettings[pluginId] == null) - state.denominationSettings[pluginId] = {} - // @ts-expect-error - this is because laziness - newState.denominationSettings[pluginId][currencyCode] ??= - currencyInfo.denominations[0] - for (const token of currencyInfo.metaTokens ?? []) { - const tokenCode = token.currencyCode - // @ts-expect-error - this is because laziness - newState.denominationSettings[pluginId][tokenCode] = - token.denominations[0] - } - } - return newState + const { walletSort } = action.data + // Denomination defaults are derived from currencyInfo on-demand via + // selectors, so we don't need to populate them here. + return { ...state, walletsSort: walletSort } } case 'ACCOUNT_INIT_COMPLETE': { @@ -154,12 +138,16 @@ export const settingsLegacy = ( case 'UI/SETTINGS/SET_DENOMINATION_KEY': { const { pluginId, currencyCode, denomination } = action.data const newDenominationSettings = { ...state.denominationSettings } - // @ts-expect-error - this is because laziness - newDenominationSettings[pluginId][currencyCode] = denomination + // Ensure pluginId object exists before setting denomination + // @ts-expect-error - DenominationSettings type mismatch with EdgeDenomination + newDenominationSettings[pluginId] = { + ...newDenominationSettings[pluginId], + [currencyCode]: denomination + } return { ...state, - ...newDenominationSettings + denominationSettings: newDenominationSettings } } @@ -217,6 +205,15 @@ export const settingsLegacy = ( } } + case 'UI/SETTINGS/SET_TOUCH_ID_SUPPORT': { + const { isTouchEnabled, isTouchSupported } = action.data + return { + ...state, + isTouchEnabled, + isTouchSupported + } + } + case 'UI/SETTINGS/SET_MOST_RECENT_WALLETS': { return { ...state, diff --git a/src/types/reduxActions.ts b/src/types/reduxActions.ts index f16e300a468..12a903515e8 100644 --- a/src/types/reduxActions.ts +++ b/src/types/reduxActions.ts @@ -148,6 +148,10 @@ export type Action = data: SecurityCheckedWallets } | { type: 'UI/SETTINGS/SET_SETTINGS_LOCK'; data: boolean } + | { + type: 'UI/SETTINGS/SET_TOUCH_ID_SUPPORT' + data: { isTouchEnabled: boolean; isTouchSupported: boolean } + } | { type: 'UI/SETTINGS/SET_USER_PAUSED_WALLETS' data: { userPausedWallets: string[] }