diff --git a/packages/legacy/app/android/app/src/main/AndroidManifest.xml b/packages/legacy/app/android/app/src/main/AndroidManifest.xml index 8685f74d6b..29b22aa521 100644 --- a/packages/legacy/app/android/app/src/main/AndroidManifest.xml +++ b/packages/legacy/app/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/packages/legacy/core/App/components/buttons/ToggleButton.tsx b/packages/legacy/core/App/components/buttons/ToggleButton.tsx index e9ad3aa27e..2dcf1def5c 100644 --- a/packages/legacy/core/App/components/buttons/ToggleButton.tsx +++ b/packages/legacy/core/App/components/buttons/ToggleButton.tsx @@ -49,6 +49,9 @@ const ToggleButton: React.FC = ({ testID={testID} accessibilityLabel="Toggle Button" accessibilityRole="switch" + accessibilityState={{ + checked: isEnabled + }} onPress={isAvailable && !disabled ? toggleAction : undefined} // Prevent onPress if not available or disabled disabled={!isAvailable || disabled} > diff --git a/packages/legacy/core/App/localization/en/index.ts b/packages/legacy/core/App/localization/en/index.ts index cf989e6539..395acfaa93 100644 --- a/packages/legacy/core/App/localization/en/index.ts +++ b/packages/legacy/core/App/localization/en/index.ts @@ -283,7 +283,12 @@ const translation = { "Warning": "Ensure only you have access to your wallet.", "UseToUnlock": "Use biometrics to unlock wallet?", "UnlockPromptTitle": "Wallet Unlock", - "UnlockPromptDescription": "Use biometrics to unlock your wallet" + "UnlockPromptDescription": "Use biometrics to unlock your wallet", + "AllowBiometricsTitle": "Enable biometrics", + "AllowBiometricsDesc": "To unlock BC Wallet with your biometrics, please allow biometrics use within your device's settings.", + "SetupBiometricsTitle": "Biometrics is not enabled", + "SetupBiometricsDesc": "To unlock BC Wallet with your biometrics, please set up your biometrics in your device's settings.", + "OpenSettings": "Open settings" }, "ActivityHistory": { "Header": "Activity history", diff --git a/packages/legacy/core/App/localization/fr/index.ts b/packages/legacy/core/App/localization/fr/index.ts index 141cdb1487..5a07f9496d 100644 --- a/packages/legacy/core/App/localization/fr/index.ts +++ b/packages/legacy/core/App/localization/fr/index.ts @@ -282,7 +282,12 @@ const translation = { "Warning": "\n\nAssurez-vous que vous seul avez accès à votre portefeuille.", "UseToUnlock": "Utiliser la biométrie pour déverrouiller le portefeuille ?", "UnlockPromptTitle": "Déverrouillage du portefeuille", - "UnlockPromptDescription": "Utilisez la biométrie pour déverrouiller votre portefeuille" + "UnlockPromptDescription": "Utilisez la biométrie pour déverrouiller votre portefeuille", + "AllowBiometricsTitle": "Activer la biométrie", + "AllowBiometricsDesc": "Pour déverrouiller BC Wallet avec votre biométrie, permettrez la biométrie dans les paramètres de votre appareil.", + "SetupBiometricsTitle": "La biométrie n'est pas activée", + "SetupBiometricsDesc": "Pour déverrouiller BC Wallet avec votre biométrie, configurez votre biométrie dans les paramètres de votre appareil.", + "OpenSettings": "Ouvrir les paramètres" }, "ActivityHistory": { "Header": "Activity history(fr)", diff --git a/packages/legacy/core/App/screens/UseBiometry.tsx b/packages/legacy/core/App/screens/UseBiometry.tsx index fe0f8c99a0..c5848fc28d 100644 --- a/packages/legacy/core/App/screens/UseBiometry.tsx +++ b/packages/legacy/core/App/screens/UseBiometry.tsx @@ -2,7 +2,8 @@ import { CommonActions, useNavigation } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' import React, { useState, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { StyleSheet, Text, View, Modal, ScrollView, DeviceEventEmitter } from 'react-native' +import { StyleSheet, Text, View, Modal, ScrollView, DeviceEventEmitter, Linking, Platform } from 'react-native' +import { PERMISSIONS, RESULTS, request, check, PermissionStatus } from 'react-native-permissions' import { SafeAreaView } from 'react-native-safe-area-context' import Button, { ButtonType } from '../components/buttons/Button' @@ -15,6 +16,7 @@ import { useStore } from '../contexts/store' import { useTheme } from '../contexts/theme' import { OnboardingStackParams, Screens } from '../types/navigators' import { testIdWithKey } from '../utils/testable' +import DismissiblePopupModal from '../components/modals/DismissiblePopupModal' import PINEnter, { PINEntryUsage } from './PINEnter' import { TOKENS, useServices } from '../container-api' @@ -32,6 +34,7 @@ const UseBiometry: React.FC = () => { const [biometryAvailable, setBiometryAvailable] = useState(false) const [biometryEnabled, setBiometryEnabled] = useState(store.preferences.useBiometry) const [continueEnabled, setContinueEnabled] = useState(true) + const [settingsPopupConfig, setSettingsPopupConfig] = useState(null) const [canSeeCheckPIN, setCanSeeCheckPIN] = useState(false) const { ColorPallet, TextTheme, Assets } = useTheme() const { ButtonLoading } = useAnimatedComponents() @@ -40,6 +43,8 @@ const UseBiometry: React.FC = () => { return store.onboarding.didCompleteOnboarding ? UseBiometryUsage.ToggleOnOff : UseBiometryUsage.InitialSetup }, [store.onboarding.didCompleteOnboarding]) + const BIOMETRY_PERMISSION = PERMISSIONS.IOS.FACE_ID; + const styles = StyleSheet.create({ container: { height: '100%', @@ -102,17 +107,88 @@ const UseBiometry: React.FC = () => { } }, [biometryEnabled, commitPIN, dispatch, enablePushNotifications, navigation]) - const toggleSwitch = useCallback(() => { - // If the user is toggling biometrics on/off they need - // to first authenticate before this action is accepted + const onOpenSettingsTouched = async () => { + await Linking.openSettings() + onOpenSettingsDismissed() + } + + const onOpenSettingsDismissed = () => { + setSettingsPopupConfig(null) + } + + const onSwitchToggleAllowed = useCallback((newValue: boolean) => { if (screenUsage === UseBiometryUsage.ToggleOnOff) { setCanSeeCheckPIN(true) DeviceEventEmitter.emit(EventTypes.BIOMETRY_UPDATE, true) + } else { + setBiometryEnabled(newValue) + } + }, [screenUsage]) + + const onRequestSystemBiometrics = useCallback(async (newToggleValue: boolean) => { + const permissionResult: PermissionStatus = await request(BIOMETRY_PERMISSION) + switch (permissionResult) { + case RESULTS.GRANTED: + case RESULTS.LIMITED: + // Granted + onSwitchToggleAllowed(newToggleValue) + break + default: + break + } + }, [onSwitchToggleAllowed, BIOMETRY_PERMISSION]) + + const onCheckSystemBiometrics = useCallback(async (): Promise => { + if (Platform.OS === 'android') { + // Android doesn't need to prompt biometric permission + // for an app to use it. + return biometryAvailable ? RESULTS.GRANTED : RESULTS.UNAVAILABLE + } else if (Platform.OS === 'ios') { + return await check(BIOMETRY_PERMISSION) + } + + return RESULTS.UNAVAILABLE + }, [biometryAvailable, BIOMETRY_PERMISSION]) + + const toggleSwitch = useCallback(async () => { + const newValue = !biometryEnabled + + if (!newValue) { + // Turning off doesn't require OS'es biometrics enabled + onSwitchToggleAllowed(newValue) return } - setBiometryEnabled((previousState) => !previousState) - }, [screenUsage]) + // If the user is turning it on, they need + // to first authenticate the OS'es biometrics before this action is accepted + const permissionResult: PermissionStatus = await onCheckSystemBiometrics() + switch (permissionResult) { + case RESULTS.GRANTED: + case RESULTS.LIMITED: + // Already granted + onSwitchToggleAllowed(newValue) + break + case RESULTS.UNAVAILABLE: + setSettingsPopupConfig({ + title: t('Biometry.SetupBiometricsTitle'), + description: t('Biometry.SetupBiometricsDesc') + }) + break + case RESULTS.BLOCKED: + // Previously denied + setSettingsPopupConfig({ + title: t('Biometry.AllowBiometricsTitle'), + description: t('Biometry.AllowBiometricsDesc') + }) + break + case RESULTS.DENIED: + // Has not been requested + await onRequestSystemBiometrics(newValue) + break + default: + break + } + }, [onSwitchToggleAllowed, onRequestSystemBiometrics, onCheckSystemBiometrics, biometryEnabled, t]) const onAuthenticationComplete = useCallback((status: boolean) => { // If successfully authenticated the toggle may proceed. @@ -125,6 +201,15 @@ const UseBiometry: React.FC = () => { return ( + {settingsPopupConfig && ( + + )} @@ -153,9 +238,9 @@ const UseBiometry: React.FC = () => { diff --git a/packages/legacy/core/__tests__/components/__snapshots__/ToggleButton.test.tsx.snap b/packages/legacy/core/__tests__/components/__snapshots__/ToggleButton.test.tsx.snap index 375b951f69..ba55974da2 100644 --- a/packages/legacy/core/__tests__/components/__snapshots__/ToggleButton.test.tsx.snap +++ b/packages/legacy/core/__tests__/components/__snapshots__/ToggleButton.test.tsx.snap @@ -7,7 +7,7 @@ exports[`ToggleButton Component renders correctly when disabled 1`] = ` accessibilityState={ Object { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": false, "expanded": undefined, "selected": undefined, @@ -100,7 +100,7 @@ exports[`ToggleButton Component renders correctly when enabled 1`] = ` accessibilityState={ Object { "busy": undefined, - "checked": undefined, + "checked": true, "disabled": false, "expanded": undefined, "selected": undefined, @@ -193,7 +193,7 @@ exports[`ToggleButton Component renders correctly when not available 1`] = ` accessibilityState={ Object { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": true, "expanded": undefined, "selected": undefined, diff --git a/packages/legacy/core/__tests__/screens/UseBiometry.test.tsx b/packages/legacy/core/__tests__/screens/UseBiometry.test.tsx index 4fef48b670..cd08e5290d 100644 --- a/packages/legacy/core/__tests__/screens/UseBiometry.test.tsx +++ b/packages/legacy/core/__tests__/screens/UseBiometry.test.tsx @@ -7,7 +7,24 @@ import { testIdWithKey } from '../../App/utils/testable' import authContext from '../contexts/auth' import timeTravel from '../helpers/timetravel' import { BasicAppContext } from '../helpers/app' +import { Linking } from 'react-native' +import { testDefaultState } from '../contexts/store' +import { StoreProvider } from '../../App/contexts/store' +import { RESULTS, check, request } from 'react-native-permissions' +jest.mock('react-native-permissions', () => require('react-native-permissions/mock')) +const mockedCheck = check as jest.MockedFunction; +const mockedRequest = request as jest.MockedFunction; + +jest.spyOn(Linking, 'openSettings').mockImplementation(() => Promise.resolve()) + +const customStore = { + ...testDefaultState, + preferences: { + ...testDefaultState.preferences, + useBiometry: false + }, +} describe('UseBiometry Screen', () => { beforeAll(() => { @@ -15,6 +32,15 @@ describe('UseBiometry Screen', () => { jest.spyOn(global.console, 'error').mockImplementation(() => { }) }) + beforeEach(() => { + jest.clearAllMocks() + + authContext.isBiometricsActive = jest.fn().mockResolvedValue(true) + customStore.preferences.useBiometry = false + mockedCheck.mockResolvedValue(RESULTS.UNAVAILABLE) + mockedRequest.mockResolvedValue(RESULTS.BLOCKED) + }) + test('Renders correctly when biometry available', async () => { authContext.isBiometricsActive = jest.fn().mockResolvedValueOnce(true) const tree = render( @@ -59,20 +85,16 @@ describe('UseBiometry Screen', () => { ) - await waitFor(() => { - timeTravel(1000) - }) - - const useBiometryToggle = await tree.getByTestId(testIdWithKey('ToggleBiometrics')) + const useBiometryToggle = tree.getByTestId(testIdWithKey('ToggleBiometrics')) await waitFor(async () => { - await fireEvent(useBiometryToggle, 'valueChange', true) + fireEvent(useBiometryToggle, 'valueChange', true) }) - const continueButton = await tree.getByTestId(testIdWithKey('Continue')) + const continueButton = tree.getByTestId(testIdWithKey('Continue')) await waitFor(async () => { - await fireEvent(continueButton, 'press') + fireEvent(continueButton, 'press') }) expect(useBiometryToggle).not.toBeNull() @@ -80,4 +102,216 @@ describe('UseBiometry Screen', () => { expect(authContext.commitPIN).toBeCalledTimes(1) expect(tree).toMatchSnapshot() }) + + describe('ToggleButton Availability', () => { + test('ToggleButton is enabled when biometry is available', async () => { + authContext.isBiometricsActive = jest.fn().mockResolvedValueOnce(true) + + const { getByTestId } = render( + + + + + + ) + + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + expect(toggleButton.props.accessibilityState.disabled).toBe(false) + }) + + test('ToggleButton is enabled even when biometry is not available', async () => { + authContext.isBiometricsActive = jest.fn().mockResolvedValueOnce(false) + + const { getByTestId } = render( + + + + + + ) + + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + expect(toggleButton.props.accessibilityState.disabled).toBe(false) + }) + }) + + describe('ToggleSwitch Behavior', () => { + test('can turn off biometrics regardless of permission status', async () => { + // Setup with toggle switch is on, + // and biometric is not available + customStore.preferences.useBiometry = true + authContext.isBiometricsActive = jest.fn().mockResolvedValueOnce(false) + + const { getByTestId } = render( + + + + + + + + ) + + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + + await waitFor(() => { + fireEvent(toggleButton, 'press') + }) + + expect(toggleButton.props.accessibilityState.checked).toBe(false) + }) + + test('turns on biometrics when permission is GRANTED', async () => { + mockedCheck.mockResolvedValueOnce(RESULTS.GRANTED) + + const { getByTestId } = render( + + + + + + + + ) + + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + + await waitFor(() => { + fireEvent(toggleButton, 'press') + }) + + expect(toggleButton.props.accessibilityState.checked).toBe(true) + }) + + test('shows settings popup when permission is UNAVAILABLE', async () => { + mockedCheck.mockResolvedValueOnce(RESULTS.UNAVAILABLE) + + const { getByTestId, getByText } = render( + + + + + + + + ) + + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + + await waitFor(() => { + fireEvent(toggleButton, 'press') + }) + + expect(getByText('Biometry.SetupBiometricsTitle')).toBeTruthy() + expect(getByText('Biometry.SetupBiometricsDesc')).toBeTruthy() + // Toggle should remain off + expect(toggleButton.props.accessibilityState.checked).toBe(false) + }) + + test('shows settings popup when permission is BLOCKED', async () => { + mockedCheck.mockResolvedValueOnce(RESULTS.BLOCKED) + + const { getByTestId, getByText } = render( + + + + + + + + ) + + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + + await waitFor(() => { + fireEvent(toggleButton, 'press') + }) + + expect(getByText('Biometry.AllowBiometricsTitle')).toBeTruthy() + expect(getByText('Biometry.AllowBiometricsDesc')).toBeTruthy() + // Toggle should remain off + expect(toggleButton.props.accessibilityState.checked).toBe(false) + }) + + test('requests permission when status is DENIED and enables when request is GRANTED', async () => { + mockedCheck.mockResolvedValueOnce(RESULTS.DENIED) + mockedRequest.mockResolvedValueOnce(RESULTS.GRANTED) + + const { getByTestId } = render( + + + + + + + + ) + + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + + await waitFor(() => { + fireEvent(toggleButton, 'press') + }) + + expect(request).toHaveBeenCalledTimes(1) + expect(toggleButton.props.accessibilityState.checked).toBe(true) + }) + }) + + test('requests permission when status is DENIED and switch stays off when request is BLOCKED', async () => { + mockedCheck.mockResolvedValueOnce(RESULTS.DENIED) + mockedRequest.mockResolvedValueOnce(RESULTS.BLOCKED) + + const { getByTestId } = render( + + + + + + + + ) + + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + + await waitFor(() => { + fireEvent(toggleButton, 'press') + }) + + expect(request).toHaveBeenCalledTimes(1) + // Switch stays off + expect(toggleButton.props.accessibilityState.checked).toBe(false) + }) + + describe('Settings Popup Behavior', () => { + test('opens app settings when Open Settings is pressed', async () => { + // Mock permission check to return BLOCKED to trigger settings popup + mockedCheck.mockResolvedValueOnce(RESULTS.BLOCKED) + + const { getByTestId, getByText } = render( + + + + + + + + ) + + // Trigger the settings popup + const toggleButton = getByTestId(testIdWithKey('ToggleBiometrics')) + + await waitFor(() => { + fireEvent(toggleButton, 'press') + }) + + // Press the Open Settings button + const openSettingsButton = getByText('Biometry.OpenSettings') + await waitFor(() => { + fireEvent(openSettingsButton, 'press') + }) + + expect(Linking.openSettings).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/legacy/core/__tests__/screens/__snapshots__/UseBiometry.test.tsx.snap b/packages/legacy/core/__tests__/screens/__snapshots__/UseBiometry.test.tsx.snap index c2e1ba5cc3..87f87a06f2 100644 --- a/packages/legacy/core/__tests__/screens/__snapshots__/UseBiometry.test.tsx.snap +++ b/packages/legacy/core/__tests__/screens/__snapshots__/UseBiometry.test.tsx.snap @@ -116,7 +116,7 @@ exports[`UseBiometry Screen Renders correctly when biometry available 1`] = ` accessibilityState={ Object { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": false, "expanded": undefined, "selected": undefined, @@ -399,8 +399,8 @@ exports[`UseBiometry Screen Renders correctly when biometry not available 1`] = accessibilityState={ Object { "busy": undefined, - "checked": undefined, - "disabled": true, + "checked": false, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -435,7 +435,7 @@ exports[`UseBiometry Screen Renders correctly when biometry not available 1`] = "borderRadius": 25, "height": 30, "justifyContent": "center", - "opacity": 0.5, + "opacity": 1, "padding": 3, "width": 55, } @@ -694,7 +694,7 @@ exports[`UseBiometry Screen Toggles use biometrics ok 1`] = ` accessibilityState={ Object { "busy": undefined, - "checked": undefined, + "checked": false, "disabled": false, "expanded": undefined, "selected": undefined,