diff --git a/app/stacks/InsideStack.tsx b/app/stacks/InsideStack.tsx index 664190e602..2a455d6751 100644 --- a/app/stacks/InsideStack.tsx +++ b/app/stacks/InsideStack.tsx @@ -98,9 +98,7 @@ const ProfileViewScreen: ComponentType> = withNavig const ChangePasswordViewScreen: ComponentType> = withNavigation(ChangePasswordView as any) as any; const UserPreferencesViewScreen: ComponentType> = withNavigation(UserPreferencesView as any) as any; const SecurityPrivacyViewScreen: ComponentType> = withNavigation(SecurityPrivacyView as any) as any; -const ScreenLockConfigViewScreen: ComponentType> = withNavigation( - ScreenLockConfigView as any -) as any; +const ScreenLockConfigViewScreen: ComponentType> = ScreenLockConfigView; const CreateDiscussionViewScreen = withNavigation(CreateDiscussionView as any) as any; const E2EEnterYourPasswordViewScreen: ComponentType> = withNavigation( E2EEnterYourPasswordView as any @@ -239,10 +237,7 @@ const SettingsStack = createNativeStackNavigator({ MediaAutoDownloadView: MediaAutoDownloadViewScreen, GetHelpView: GetHelpViewScreen, LegalView: LegalViewScreen, - ScreenLockConfigView: createNativeStackScreen({ - screen: ScreenLockConfigViewScreen, - options: (): NativeStackNavigationOptions => (ScreenLockConfigView as any).navigationOptions() - }) + ScreenLockConfigView: ScreenLockConfigViewScreen } }).with(({ Navigator }) => { 'use memo'; diff --git a/app/stacks/MasterDetailStack/index.tsx b/app/stacks/MasterDetailStack/index.tsx index a4e517d204..b368228a5c 100644 --- a/app/stacks/MasterDetailStack/index.tsx +++ b/app/stacks/MasterDetailStack/index.tsx @@ -104,9 +104,6 @@ const TeamChannelsViewScreen: ComponentType> = withNavigation( ReadReceiptsView as any ) as any; -const ScreenLockConfigViewScreen: ComponentType> = withNavigation( - ScreenLockConfigView as any -) as any; const RoomInfoEditViewScreen: ComponentType> = withNavigation( RoomInfoEditView as any @@ -225,10 +222,7 @@ const ModalStack = createNativeStackNavigator({ LanguageView, ThemeView, DefaultBrowserView, - ScreenLockConfigView: createNativeStackScreen({ - screen: ScreenLockConfigViewScreen, - options: ScreenLockConfigView.navigationOptions - }), + ScreenLockConfigView, StatusView, ProfileView: ProfileViewScreen, ChangePasswordView: ChangePasswordViewScreen, diff --git a/app/views/ScreenLockConfigView.test.tsx b/app/views/ScreenLockConfigView.test.tsx new file mode 100644 index 0000000000..61dce2dbdb --- /dev/null +++ b/app/views/ScreenLockConfigView.test.tsx @@ -0,0 +1,163 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import ScreenLockConfigView from './ScreenLockConfigView'; +import { BIOMETRY_ENABLED_KEY, DEFAULT_AUTO_LOCK } from '../lib/constants/localAuthentication'; + +const mockSetOptions = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ setOptions: mockSetOptions }) +})); + +jest.mock('../theme', () => ({ + useTheme: () => ({ theme: 'light', colors: {} }) +})); + +jest.mock('../lib/hooks/useAppSelector', () => ({ + useAppSelector: (selector: (state: any) => unknown) => + selector({ + server: { server: 'srv' }, + settings: { Force_Screen_Lock: false, Force_Screen_Lock_After: 0 } + }) +})); + +const update = jest.fn(cb => cb({ autoLock: false, autoLockTime: null })); +const fakeRecord = { autoLock: false, autoLockTime: null, update }; + +const mockWrite = jest.fn(fn => fn()); +const mockFind = jest.fn(() => Promise.resolve(fakeRecord)); + +jest.mock('../lib/database', () => ({ + __esModule: true, + default: { + servers: { + get: () => ({ find: mockFind }), + write: (fn: () => Promise) => mockWrite(fn) + } + } +})); + +const mockSupportedBiometryLabel = jest.fn(() => Promise.resolve('Face ID')); +const mockCheckHasPasscode = jest.fn(); + +jest.mock('../lib/methods/helpers/localAuthentication', () => ({ + supportedBiometryLabel: () => mockSupportedBiometryLabel(), + checkHasPasscode: (args: any) => mockCheckHasPasscode(args), + changePasscode: jest.fn(), + handleLocalAuthentication: jest.fn() +})); + +const mockGetBool = jest.fn((_key: string) => false as boolean | null); +const mockSetBool = jest.fn((_key: string, _value: boolean) => {}); + +jest.mock('../lib/methods/userPreferences', () => ({ + __esModule: true, + default: { + getBool: (key: string) => mockGetBool(key), + setBool: (key: string, value: boolean) => mockSetBool(key, value) + } +})); + +jest.mock('../lib/methods/helpers/log', () => ({ + events: { + SLC_SAVE_SCREEN_LOCK: 'SLC_SAVE_SCREEN_LOCK', + SLC_TOGGLE_AUTOLOCK: 'SLC_TOGGLE_AUTOLOCK', + SLC_TOGGLE_BIOMETRY: 'SLC_TOGGLE_BIOMETRY', + SLC_CHANGE_AUTOLOCK_TIME: 'SLC_CHANGE_AUTOLOCK_TIME', + SLC_CHANGE_PASSCODE: 'SLC_CHANGE_PASSCODE' + }, + logEvent: jest.fn() +})); + +jest.mock('../containers/SafeAreaView', () => { + const { View } = require('react-native'); + return ({ children }: any) => {children}; +}); + +describe('ScreenLockConfigView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFind.mockResolvedValue(fakeRecord); + mockSupportedBiometryLabel.mockResolvedValue('Face ID'); + mockGetBool.mockReturnValue(false); + }); + + it('renders the auto-lock list item after init', async () => { + const { findByText } = render(); + + await findByText('Unlock with passcode'); + }); + + it('enable auto-lock persists true — stale-state guard', async () => { + mockCheckHasPasscode.mockResolvedValue(undefined); + + const { findByTestId } = render(); + const autoLockSwitch = await findByTestId('screen-lock-config-auto-lock-switch'); + fireEvent(autoLockSwitch, 'onValueChange', true); + + await waitFor(() => expect(mockWrite).toHaveBeenCalled()); + + expect(update).toHaveBeenCalled(); + const recordArg: any = {}; + const cb = update.mock.calls[update.mock.calls.length - 1][0]; + cb(recordArg); + expect(recordArg.autoLock).toBe(true); + expect(recordArg.autoLockTime).toBe(DEFAULT_AUTO_LOCK); + }); + + it('passcode-cancel undo — ends with autoLock false persisted', async () => { + mockCheckHasPasscode.mockRejectedValue(new Error('cancelled')); + + const { findByTestId } = render(); + const autoLockSwitch = await findByTestId('screen-lock-config-auto-lock-switch'); + fireEvent(autoLockSwitch, 'onValueChange', true); + + await waitFor(() => expect(mockWrite).toHaveBeenCalled()); + + expect(update).toHaveBeenCalled(); + const recordArg: any = {}; + const cb = update.mock.calls[update.mock.calls.length - 1][0]; + cb(recordArg); + expect(recordArg.autoLock).toBe(false); + expect(recordArg.autoLockTime).toBe(DEFAULT_AUTO_LOCK); + }); + + it('toggle biometry calls userPreferences.setBool with flipped value', async () => { + mockCheckHasPasscode.mockResolvedValue(undefined); + + const { findByTestId } = render(); + const autoLockSwitch = await findByTestId('screen-lock-config-auto-lock-switch'); + fireEvent(autoLockSwitch, 'onValueChange', true); + await waitFor(() => expect(mockWrite).toHaveBeenCalled()); + + mockWrite.mockClear(); + + const biometrySwitch = await findByTestId('screen-lock-config-biometry-switch'); + fireEvent(biometrySwitch, 'onValueChange', true); + + await waitFor(() => expect(mockSetBool).toHaveBeenCalledWith(BIOMETRY_ENABLED_KEY, true)); + }); + + it('change auto-lock time persists selected value with current autoLock', async () => { + mockCheckHasPasscode.mockResolvedValue(undefined); + + const { findByTestId, findByText } = render(); + const autoLockSwitch = await findByTestId('screen-lock-config-auto-lock-switch'); + fireEvent(autoLockSwitch, 'onValueChange', true); + await waitFor(() => expect(mockWrite).toHaveBeenCalled()); + + mockWrite.mockClear(); + update.mockClear(); + + const timeOption = await findByText('After 1 minute'); + fireEvent.press(timeOption.parent!); + + await waitFor(() => expect(mockWrite).toHaveBeenCalled()); + + expect(update).toHaveBeenCalled(); + const recordArg: any = {}; + const cb = update.mock.calls[update.mock.calls.length - 1][0]; + cb(recordArg); + expect(recordArg.autoLockTime).toBe(60); + }); +}); diff --git a/app/views/ScreenLockConfigView.tsx b/app/views/ScreenLockConfigView.tsx index dc5f2f92d4..a9310802ff 100644 --- a/app/views/ScreenLockConfigView.tsx +++ b/app/views/ScreenLockConfigView.tsx @@ -1,9 +1,9 @@ -import { connect } from 'react-redux'; -import { type Subscription } from 'rxjs'; -import { Component } from 'react'; +import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'; +import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useNavigation } from '@react-navigation/native'; import I18n from '../i18n'; -import { type TSupportedThemes, withTheme } from '../theme'; +import { useTheme } from '../theme'; import * as List from '../containers/List'; import database from '../lib/database'; import { @@ -13,12 +13,13 @@ import { handleLocalAuthentication } from '../lib/methods/helpers/localAuthentication'; import { BIOMETRY_ENABLED_KEY, DEFAULT_AUTO_LOCK } from '../lib/constants/localAuthentication'; -import { themes } from '../lib/constants/colors'; import SafeAreaView from '../containers/SafeAreaView'; import { events, logEvent } from '../lib/methods/helpers/log'; import userPreferences from '../lib/methods/userPreferences'; -import { type IApplicationState, type TServerModel } from '../definitions'; +import { type TServerModel } from '../definitions'; import Switch from '../containers/Switch'; +import { type SettingsStackParamList } from '../stacks/types'; +import { useAppSelector } from '../lib/hooks/useAppSelector'; const DEFAULT_BIOMETRY = false; @@ -28,197 +29,138 @@ interface IItem { disabled?: boolean; } -interface IScreenLockConfigViewProps { - theme: TSupportedThemes; - server: string; - Force_Screen_Lock: boolean; - Force_Screen_Lock_After: number; -} - -interface IScreenLockConfigViewState { - autoLock: boolean; - autoLockTime?: number | null; - biometry: boolean; - biometryLabel: string | null; -} - -class ScreenLockConfigView extends Component { - private serverRecord?: TServerModel; - - private observable?: Subscription; - - static navigationOptions = () => ({ - title: I18n.t('Screen_lock') - }); - - constructor(props: IScreenLockConfigViewProps) { - super(props); - this.state = { - autoLock: false, - autoLockTime: null, - biometry: DEFAULT_BIOMETRY, - biometryLabel: null +const ScreenLockConfigView = (): ReactElement => { + const navigation = useNavigation>(); + const { colors } = useTheme(); + + const server = useAppSelector(state => state.server.server); + const Force_Screen_Lock = useAppSelector(state => state.settings.Force_Screen_Lock as boolean); + const Force_Screen_Lock_After = useAppSelector(state => state.settings.Force_Screen_Lock_After as number); + + const serverRecord = useRef(undefined); + + const [autoLock, setAutoLock] = useState(false); + const [autoLockTime, setAutoLockTime] = useState(null); + const [biometry, setBiometry] = useState(DEFAULT_BIOMETRY); + const [biometryLabel, setBiometryLabel] = useState(null); + + useLayoutEffect(() => { + navigation.setOptions({ title: I18n.t('Screen_lock') }); + }, [navigation]); + + useEffect(() => { + const init = async () => { + try { + serverRecord.current = await database.servers.get('servers').find(server); + setAutoLock(serverRecord.current?.autoLock ?? false); + setAutoLockTime( + serverRecord.current?.autoLockTime === null ? DEFAULT_AUTO_LOCK : serverRecord.current?.autoLockTime ?? null + ); + setBiometry(userPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? DEFAULT_BIOMETRY); + } catch { + // noop + } + setBiometryLabel(await supportedBiometryLabel()); }; - this.init(); - } - - componentWillUnmount() { - if (this.observable && this.observable.unsubscribe) { - this.observable.unsubscribe(); - } - } - - defaultAutoLockOptions = [ - { - title: I18n.t('Local_authentication_auto_lock_60'), - value: 60 - }, - { - title: I18n.t('Local_authentication_auto_lock_300'), - value: 300 - }, - { - title: I18n.t('Local_authentication_auto_lock_900'), - value: 900 - }, - { - title: I18n.t('Local_authentication_auto_lock_1800'), - value: 1800 - }, - { - title: I18n.t('Local_authentication_auto_lock_3600'), - value: 3600 - } - ]; + init(); + }, [server]); - init = async () => { - const { server } = this.props; - const serversDB = database.servers; - const serversCollection = serversDB.get('servers'); - try { - this.serverRecord = await serversCollection.find(server); - this.setState( - { - autoLock: this.serverRecord?.autoLock, - autoLockTime: this.serverRecord?.autoLockTime === null ? DEFAULT_AUTO_LOCK : this.serverRecord?.autoLockTime - }, - () => this.hasBiometry() - ); - } catch (error) { - // Do nothing - } - - const biometryLabel = await supportedBiometryLabel(); - this.setState({ biometryLabel }); - }; - - save = async () => { + const save = async (nextAutoLock: boolean, nextAutoLockTime: number | null) => { logEvent(events.SLC_SAVE_SCREEN_LOCK); - const { autoLock, autoLockTime } = this.state; - const serversDB = database.servers; - await serversDB.write(async () => { - await this.serverRecord?.update(record => { - record.autoLock = autoLock; - record.autoLockTime = autoLockTime === null ? DEFAULT_AUTO_LOCK : autoLockTime; + await database.servers.write(async () => { + await serverRecord.current?.update(record => { + record.autoLock = nextAutoLock; + record.autoLockTime = nextAutoLockTime === null ? DEFAULT_AUTO_LOCK : nextAutoLockTime; }); }); }; - hasBiometry = () => { - const biometry = userPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? DEFAULT_BIOMETRY; - this.setState({ biometry }); - }; - - changePasscode = async ({ force }: { force: boolean }) => { - const { autoLock } = this.state; - if (autoLock) { - await handleLocalAuthentication(true); - } - logEvent(events.SLC_CHANGE_PASSCODE); - await changePasscode({ force }); - }; - - toggleAutoLock = () => { + const toggleAutoLock = async () => { logEvent(events.SLC_TOGGLE_AUTOLOCK); - this.setState( - ({ autoLock }) => ({ autoLock: !autoLock, autoLockTime: DEFAULT_AUTO_LOCK }), - async () => { - const { autoLock } = this.state; - if (autoLock) { - try { - await checkHasPasscode({ force: false }); - this.hasBiometry(); - } catch { - this.toggleAutoLock(); - } - } - this.save(); + const nextAutoLock = !autoLock; + setAutoLock(nextAutoLock); + setAutoLockTime(DEFAULT_AUTO_LOCK); + if (nextAutoLock) { + try { + await checkHasPasscode({ force: false }); + setBiometry(userPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? DEFAULT_BIOMETRY); + } catch { + setAutoLock(false); + setAutoLockTime(DEFAULT_AUTO_LOCK); + await save(false, DEFAULT_AUTO_LOCK); + return; } - ); + } + await save(nextAutoLock, DEFAULT_AUTO_LOCK); }; - toggleBiometry = () => { + const toggleBiometry = () => { logEvent(events.SLC_TOGGLE_BIOMETRY); - this.setState( - ({ biometry }) => ({ biometry: !biometry }), - () => { - const { biometry } = this.state; - userPreferences.setBool(BIOMETRY_ENABLED_KEY, biometry); - } - ); + const nextBiometry = !biometry; + setBiometry(nextBiometry); + userPreferences.setBool(BIOMETRY_ENABLED_KEY, nextBiometry); }; - isSelected = (value: number) => { - const { autoLockTime } = this.state; - return autoLockTime === value; - }; + const isSelected = (value: number) => autoLockTime === value; - changeAutoLockTime = (autoLockTime: number) => { + const changeAutoLockTime = (nextAutoLockTime: number) => { logEvent(events.SLC_CHANGE_AUTOLOCK_TIME); - this.setState({ autoLockTime }, () => this.save()); + setAutoLockTime(nextAutoLockTime); + save(autoLock, nextAutoLockTime); }; - renderIcon = () => { - const { theme } = this.props; - return ; + const handleChangePasscode = async ({ force }: { force: boolean }) => { + if (autoLock) { + await handleLocalAuthentication(true); + } + logEvent(events.SLC_CHANGE_PASSCODE); + await changePasscode({ force }); }; - renderItem = ({ item }: { item: IItem }) => { + const renderIcon = () => ; + + const renderItem = ({ item }: { item: IItem }) => { const { title, value, disabled } = item; return ( - <> + this.changeAutoLockTime(value)} - right={() => (this.isSelected(value) ? this.renderIcon() : null)} + onPress={() => changeAutoLockTime(value)} + right={() => (isSelected(value) ? renderIcon() : null)} disabled={disabled} translateTitle={false} - additionalAccessibilityLabel={this.isSelected(value)} + additionalAccessibilityLabel={isSelected(value)} additionalAccessibilityLabelCheck /> - + ); }; - renderAutoLockSwitch = () => { - const { autoLock } = this.state; - const { Force_Screen_Lock } = this.props; - return ; - }; + const renderAutoLockSwitch = () => ( + + ); - renderBiometrySwitch = () => { - const { biometry } = this.state; - return ; - }; + const renderBiometrySwitch = () => ( + + ); - renderAutoLockItems = () => { - const { autoLock, autoLockTime } = this.state; - const { Force_Screen_Lock_After, Force_Screen_Lock } = this.props; + const renderAutoLockItems = () => { if (!autoLock) { return null; } - let items: IItem[] = this.defaultAutoLockOptions; + let items: IItem[] = [ + { title: I18n.t('Local_authentication_auto_lock_60'), value: 60 }, + { title: I18n.t('Local_authentication_auto_lock_300'), value: 300 }, + { title: I18n.t('Local_authentication_auto_lock_900'), value: 900 }, + { title: I18n.t('Local_authentication_auto_lock_1800'), value: 1800 }, + { title: I18n.t('Local_authentication_auto_lock_3600'), value: 3600 } + ]; if (Force_Screen_Lock && Force_Screen_Lock_After > 0) { items = [ { @@ -227,7 +169,6 @@ class ScreenLockConfigView extends Component item.value === autoLockTime)) { items.push({ title: I18n.t('After_seconds_set_by_admin', { seconds: Force_Screen_Lock_After }), @@ -237,13 +178,12 @@ class ScreenLockConfigView extends Component - <>{items.map(item => this.renderItem({ item }))} + <>{items.map(item => renderItem({ item }))} ); }; - renderBiometry = () => { - const { autoLock, biometryLabel } = this.state; + const renderBiometry = () => { if (!autoLock || !biometryLabel) { return null; } @@ -252,48 +192,39 @@ class ScreenLockConfigView extends Component this.renderBiometrySwitch()} + right={() => renderBiometrySwitch()} translateTitle={false} - additionalAccessibilityLabel={this.state.biometry ? I18n.t('Enabled') : I18n.t('Disabled')} + additionalAccessibilityLabel={biometry ? I18n.t('Enabled') : I18n.t('Disabled')} /> ); }; - render() { - const { autoLock } = this.state; - return ( - - - - - this.renderAutoLockSwitch()} - additionalAccessibilityLabel={autoLock} - /> - {autoLock ? ( - <> - - - - ) : null} - - - - {this.renderBiometry()} - {this.renderAutoLockItems()} - - - ); - } -} - -const mapStateToProps = (state: IApplicationState) => ({ - server: state.server.server, - Force_Screen_Lock: state.settings.Force_Screen_Lock as boolean, - Force_Screen_Lock_After: state.settings.Force_Screen_Lock_After as number -}); - -export default connect(mapStateToProps)(withTheme(ScreenLockConfigView)); + return ( + + + + + renderAutoLockSwitch()} + additionalAccessibilityLabel={autoLock} + /> + {autoLock ? ( + <> + + + + ) : null} + + + + {renderBiometry()} + {renderAutoLockItems()} + + + ); +}; + +export default ScreenLockConfigView;