Skip to content
Open
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 @@ -17,6 +17,7 @@
- changed: Migrate Monero to the react-native-monero implementation, replacing edge-currency-monero
- changed: Migrate package manager from yarn to npm.
- fixed: Android build failure from the home screen long-press shortcuts feature, caused by an expo-quick-actions Kotlin compile error under Kotlin 2.3.
- fixed: Use Biometrics toggle in Settings reverting to its previous state after leaving and re-entering the scene.

## 4.48.2 (2026-06-03)

Expand Down
106 changes: 106 additions & 0 deletions src/__tests__/scenes/SettingsScene.biometric.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it, jest } from '@jest/globals'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, waitFor } from '@testing-library/react-native'
import * as React from 'react'

import { SettingsScene } from '../../components/scenes/SettingsScene'
import { FakeProviders, type FakeState } from '../../util/fake/FakeProviders'
import { fakeEdgeAppSceneProps } from '../../util/fake/fakeSceneProps'

// Stateful biometric backend, standing in for the keychain that
// `edge-login-ui-rn` persists the "Use Biometrics" setting to. The `mock`
// prefix is required for jest to allow referencing it inside the factory.
const mockBiometry = { enabled: true }
jest.mock('edge-login-ui-rn', () => ({
getSupportedBiometryType: async () => 'FaceID',
isTouchEnabled: async () => mockBiometry.enabled,
enableTouchId: async () => {
mockBiometry.enabled = true
},
disableTouchId: async () => {
mockBiometry.enabled = false
}
}))

const ACCOUNT_ID = 'fake-account-id'
const BIOMETRIC_QUERY_KEY = ['biometricState', ACCOUNT_ID]

const mockState: FakeState = {
core: {
account: {
id: ACCOUNT_ID,
rootLoginId: 'XXX',
currencyConfig: {},
username: 'some user',
watch: () => () => {}
},
context: {
logSettings: { defaultLogLevel: 'silent' },
watch: () => () => {}
}
}
}

const getCachedTouchEnabled = (client: QueryClient): boolean | undefined =>
client.getQueryData<{ isTouchEnabled: boolean }>(BIOMETRIC_QUERY_KEY)
?.isTouchEnabled

const renderSettings = (client: QueryClient): ReturnType<typeof render> =>
render(
<FakeProviders initialState={mockState}>
<QueryClientProvider client={client}>
<SettingsScene
{...fakeEdgeAppSceneProps('settingsOverview', undefined)}
/>
</QueryClientProvider>
</FakeProviders>
)

describe('SettingsScene biometric toggle persistence', () => {
it('keeps the cached biometric state in sync after toggling so re-entry shows the set value', async () => {
jest.useRealTimers()
mockBiometry.enabled = true

// A client we own and can inspect, shared across both scene mounts to
// model the app-level query cache that survives a scene remount. Seed it
// with the loaded biometric state so the toggle renders deterministically.
const client = new QueryClient({
defaultOptions: { queries: { retry: false } }
})
client.setQueryData(BIOMETRIC_QUERY_KEY, {
isTouchEnabled: true,
isTouchSupported: true,
biometryType: 'FaceID'
})

const rendered = renderSettings(client)

// The toggle renders once the local state syncs from the cached value.
await rendered.findByText('Use FaceID', {}, { timeout: 15000 })
expect(getCachedTouchEnabled(client)).toBe(true)

// Toggle biometrics off.
fireEvent.press(rendered.getByText('Use FaceID'))

// The persisted backend flips off AND the query cache is kept in sync.
// Before the fix the cache stayed `true`, so re-entering Settings showed
// the stale (on) state.
await waitFor(
() => {
expect(mockBiometry.enabled).toBe(false)
expect(getCachedTouchEnabled(client)).toBe(false)
},
{ timeout: 15000 }
)

rendered.unmount()

// Re-enter Settings: a fresh mount reads the same (now-correct) cache and
// shows the toggle in the state the user left it.
const reentered = renderSettings(client)
await reentered.findByText('Use FaceID', {}, { timeout: 15000 })
expect(getCachedTouchEnabled(client)).toBe(false)

reentered.unmount()
}, 60000)
})
14 changes: 13 additions & 1 deletion src/components/scenes/SettingsScene.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { asMaybe } from 'cleaners'
import type { EdgeLogType } from 'edge-core-js'
import {
Expand Down Expand Up @@ -84,6 +84,8 @@ export const SettingsScene: React.FC<Props> = props => {

const account = useSelector(state => state.core.account)

const queryClient = useQueryClient()

// Load biometric state locally (not from Redux)
const { data: biometricState } = useQuery({
queryKey: ['biometricState', account.id],
Expand Down Expand Up @@ -223,6 +225,16 @@ export const SettingsScene: React.FC<Props> = props => {
} else {
await disableTouchId(account)
}
// Keep the cached biometric state in sync with the value we just
// persisted. Without this, the cached query result stays stale and
// re-entering Settings shows the previous toggle state instead of the
// one the user set.
if (biometricState != null) {
queryClient.setQueryData(['biometricState', account.id], {
...biometricState,
isTouchEnabled: newValue
})
}
} catch (error: unknown) {
// Revert on error
setTouchIdEnabled(!newValue)
Expand Down
Loading