Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- changed: ramps: Infinite buy support according to new API
- changed: Optimize login performance.
- changed: Update Monero LWS server name to "Edge LWS"
- fixed: Light account/backup reminder notification banner sometimes missing on login
- fixed: ramps: Various Infinite UI/UX issues
- fixed: Search keyboard not dismissing when submitting search
- fixed: Auto-correct not disabled for search input
Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/actions/RequestReviewActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ 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,
resetLocalAccountSettingsCache
} from '../../actions/LocalSettingsActions'
import {
DEPOSIT_AMOUNT_THRESHOLD,
FIAT_PURCHASE_COUNT_THRESHOLD,
Expand Down Expand Up @@ -102,6 +105,8 @@ jest.mock('react-native-in-app-review', () => ({

describe('RequestReviewActions', () => {
beforeEach(async () => {
// Reset the module-level cache to avoid data persistence between tests
resetLocalAccountSettingsCache()
// Create a fresh disklet for each test to avoid data persistence between tests
mockDisklet = makeMemoryDisklet()
mockAccount = {
Expand Down
25 changes: 24 additions & 1 deletion src/actions/LocalSettingsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ watchAccountSettings(s => {
})

let readSettingsFromDisk = false

/**
* Resets the local account settings cache. Must be called on logout to prevent
* one account's settings from persisting to a subsequent account's session.
*/
export const resetLocalAccountSettingsCache = (): void => {
readSettingsFromDisk = false
localAccountSettings = asLocalAccountSettings({})
}

export const getLocalAccountSettings = async (
account: EdgeAccount
): Promise<LocalAccountSettings> => {
Expand Down Expand Up @@ -214,7 +224,12 @@ const writeAccountNotifState = async (
const localSettings = await getLocalAccountSettings(account)
return await writeLocalAccountSettings(account, {
...localSettings,
notifState
// Merge with existing notifState to prevent concurrent writes from
// overwriting each other's keys
notifState: {
...localSettings.notifState,
...notifState
}
})
}

Expand Down Expand Up @@ -261,6 +276,13 @@ export const writeTokenWarningsShown = async (
export const readLocalAccountSettings = async (
account: EdgeAccount
): Promise<LocalAccountSettings> => {
// If we've already read from disk, return the cached settings.
// This prevents stale disk reads from overwriting newer in-memory writes
// that may not have been persisted to disk yet.
if (readSettingsFromDisk) {
return localAccountSettings
Comment on lines +279 to +283

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset cached settings per account

The new short-circuit in readLocalAccountSettings returns the module‑level localAccountSettings whenever readSettingsFromDisk is true, but that flag is global and never reset on logout/account switch. If a user logs out and logs into a different account without restarting the app, this will reuse the previous account’s settings instead of reading the new account’s Settings.json, so notification state, reminders, and other local settings can be wrong for the new account. This regression is introduced by the unconditional cache return here.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What the AI said. It's a super-easy bug to miss, and we've made this same mistake several times in the past.

We generally avoid reading settings async, but instead just grab them from redux. With Sam's new login optimizations, the local settings should be loaded before we show any UI, so you can just rely on that.

}

try {
const text = await account.localDisklet.getText(LOCAL_SETTINGS_FILENAME)
const json = JSON.parse(text)
Expand All @@ -273,6 +295,7 @@ export const readLocalAccountSettings = async (
// Defaults can be derived from cleaners. Only write when values change.
const defaults = asLocalAccountSettings({})
emitAccountSettings(defaults)
readSettingsFromDisk = true
return defaults
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/actions/LoginActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ import {
getDeviceSettings,
writeIsSurveyDiscoverShown
} from './DeviceSettingsActions'
import { readLocalAccountSettings } from './LocalSettingsActions'
import {
readLocalAccountSettings,
resetLocalAccountSettingsCache
} from './LocalSettingsActions'
import {
registerNotificationsV2,
updateNotificationSettings
Expand Down Expand Up @@ -333,6 +336,7 @@ export function logoutRequest(
const { account } = state.core
Keyboard.dismiss()
Airship.clear()
resetLocalAccountSettingsCache()

dispatch({ type: 'LOGOUT' })
if (typeof account.logout === 'function') await account.logout()
Expand Down
50 changes: 32 additions & 18 deletions src/components/services/NotificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const NotificationService: React.FC<Props> = (props: Props) => {

const wallets = useWatch(account, 'currencyWallets')
const otpKey = useWatch(account, 'otpKey')
const username = useWatch(account, 'username')

const detectedTokensRedux = useSelector(
state => state.core.enabledDetectedTokens
Expand All @@ -124,7 +125,7 @@ export const NotificationService: React.FC<Props> = (props: Props) => {
// we only need referral-based promoId targeting.
const accountFunded = useIsAccountFunded()

const isLightAccountReminder = account.id != null && account.username == null
const isLightAccountReminder = account.id != null && username == null

const isOtpReminder =
otpKey == null &&
Expand All @@ -146,6 +147,8 @@ export const NotificationService: React.FC<Props> = (props: Props) => {
// Update notification info with
// 1. Date last received if transitioning from incomplete to complete
// 2. Reset `isBannerHidden` if it's a new notification
// NOTE: lightAccountReminder is handled by a separate effect below to
// preserve banner dismissal during the current session.
useAsyncEffect(
async () => {
// New token(s) detected
Expand All @@ -165,11 +168,6 @@ export const NotificationService: React.FC<Props> = (props: Props) => {
}

await updateNotificationInfo(account, 'ip2FaReminder', isIp2faReminder)
await updateNotificationInfo(
account,
'lightAccountReminder',
isLightAccountReminder
)
await updateNotificationInfo(
account,
'otpReminder',
Expand Down Expand Up @@ -201,12 +199,10 @@ export const NotificationService: React.FC<Props> = (props: Props) => {
},
[
isIp2faReminder,
isLightAccountReminder,
isOtpReminder,
isPwReminder,
wallets,
detectedTokensRedux,
notifState,
accountReferral,
accountReferralLoaded,
countryCode,
Expand All @@ -215,20 +211,38 @@ export const NotificationService: React.FC<Props> = (props: Props) => {
'NotificationServices'
)

// Make sure the backup banner is always shown on login if needed. We do this
// separately in this effect so that we can hide the banner during current
// login session if they so choose.
// Handle lightAccountReminder separately with minimal dependencies so the
// banner stays dismissed during the current session. This effect only runs
// on login (mount) and when the user backs up (isLightAccountReminder
// changes). The merge logic in writeAccountNotifState prevents race
// conditions with the main effect above.
useAsyncEffect(
async () => {
if (!isLightAccountReminder) return

await writeAccountNotifInfo(account, 'lightAccountReminder', {
isBannerHidden: false,
isCompleted: false
})
if (isLightAccountReminder) {
// Light account: always show the backup reminder banner on login
await writeAccountNotifInfo(account, 'lightAccountReminder', {
isPriority: true,
isBannerHidden: false,
isCompleted: false
})
} else {
// Non-light account: mark complete if notification exists and isn't
// already complete (handles upgrade from light account)
const { notifState: currentNotifState } = await getLocalAccountSettings(
account
)
const existing = currentNotifState.lightAccountReminder
if (existing != null && !existing.isCompleted) {
await writeAccountNotifInfo(account, 'lightAccountReminder', {
isPriority: true,
isBannerHidden: true,
isCompleted: true
})
}
}
},
[isLightAccountReminder],
'NotificationServices'
'NotificationServices:lightAccountReminder'
)

return null
Expand Down
Loading