From a5f3cc5223212deb0fe3ca716a0d81302f9be6b1 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 5 Nov 2024 19:02:42 +0100 Subject: [PATCH] feat: manual biometrics and background lock (#201) Signed-off-by: Jan Signed-off-by: Timo Glastra Co-authored-by: Timo Glastra --- apps/easypid/src/app/(app)/_layout.tsx | 1 - .../easypid/src/app/(app)/pinConfirmation.tsx | 3 +- apps/easypid/src/app/_layout.tsx | 10 ++-- apps/easypid/src/app/authenticate.tsx | 23 +++++++++- .../onboarding/screens/id-card-pin.tsx | 6 ++- .../src/features/onboarding/screens/pin.tsx | 4 +- .../src/features/share/slides/PinSlide.tsx | 4 +- .../wallet/FunkePidConfirmationScreen.tsx | 4 +- apps/easypid/src/hooks/useBiometricsType.tsx | 23 ++++++++++ .../src/components/PinDotsInput.tsx | 36 ++++++++++++--- packages/app/src/components/index.ts | 1 + packages/app/src/hooks/useHaptics.tsx | 12 ++--- .../src/provider/BackgroundLockProvider.tsx | 39 ++++++++++++++++ packages/app/src/provider/index.ts | 1 + packages/app/src/utils/DeeplinkHandler.tsx | 2 +- packages/ui/assets/FaceId.tsx | 46 +++++++++++++++++++ packages/ui/src/components/PinPad.tsx | 32 ++++++++++--- packages/ui/src/components/index.tsx | 1 - packages/ui/src/content/Icon.tsx | 2 + packages/ui/src/panels/FloatingSheet.tsx | 2 +- 20 files changed, 214 insertions(+), 38 deletions(-) create mode 100644 apps/easypid/src/hooks/useBiometricsType.tsx rename packages/{ui => app}/src/components/PinDotsInput.tsx (83%) create mode 100644 packages/app/src/provider/BackgroundLockProvider.tsx create mode 100644 packages/ui/assets/FaceId.tsx diff --git a/apps/easypid/src/app/(app)/_layout.tsx b/apps/easypid/src/app/(app)/_layout.tsx index b49d2e4b..75ad832c 100644 --- a/apps/easypid/src/app/(app)/_layout.tsx +++ b/apps/easypid/src/app/(app)/_layout.tsx @@ -15,7 +15,6 @@ const jsonRecordIds = [seedCredentialStorage.recordId, activityStorage.recordId] // When deeplink routing we want to push export const credentialDataHandlerOptions = { - allowedInvitationTypes: ['openid-authorization-request'], routeMethod: 'push', } satisfies CredentialDataHandlerOptions diff --git a/apps/easypid/src/app/(app)/pinConfirmation.tsx b/apps/easypid/src/app/(app)/pinConfirmation.tsx index cd07d00d..18ebe67a 100644 --- a/apps/easypid/src/app/(app)/pinConfirmation.tsx +++ b/apps/easypid/src/app/(app)/pinConfirmation.tsx @@ -1,6 +1,7 @@ import { FunkePidConfirmationScreen } from '@easypid/features/wallet/FunkePidConfirmationScreen' -import { HeroIcons, type PinDotsInputRef, XStack } from '@package/ui' +import { HeroIcons, XStack } from '@package/ui' import { useGlobalSearchParams, useNavigation, useRouter } from 'expo-router' +import type { PinDotsInputRef } from 'packages/app/src' import { useEffect, useRef, useState } from 'react' export default function Screen() { diff --git a/apps/easypid/src/app/_layout.tsx b/apps/easypid/src/app/_layout.tsx index cf853e46..68169ce7 100644 --- a/apps/easypid/src/app/_layout.tsx +++ b/apps/easypid/src/app/_layout.tsx @@ -1,4 +1,4 @@ -import { NoInternetToastProvider, Provider, useTransparentNavigationBar } from '@package/app' +import { BackgroundLockProvider, NoInternetToastProvider, Provider, useTransparentNavigationBar } from '@package/app' import { SecureUnlockProvider } from '@package/secure-store/secureUnlock' import { DefaultTheme, ThemeProvider } from '@react-navigation/native' import { Slot } from 'expo-router' @@ -28,9 +28,11 @@ export default function RootLayout() { }, }} > - - - + + + + + diff --git a/apps/easypid/src/app/authenticate.tsx b/apps/easypid/src/app/authenticate.tsx index a98c91a7..8978a71f 100644 --- a/apps/easypid/src/app/authenticate.tsx +++ b/apps/easypid/src/app/authenticate.tsx @@ -2,9 +2,11 @@ import { Redirect } from 'expo-router' import { WalletInvalidKeyError } from '@credo-ts/core' import { initializeAppAgent, useSecureUnlock } from '@easypid/agent' +import { useBiometricsType } from '@easypid/hooks/useBiometricsType' import { secureWalletKey } from '@package/secure-store/secureUnlock' -import { FlexPage, Heading, HeroIcons, PinDotsInput, type PinDotsInputRef, YStack } from '@package/ui' +import { FlexPage, Heading, HeroIcons, YStack, useToastController } from '@package/ui' import * as SplashScreen from 'expo-splash-screen' +import { PinDotsInput, type PinDotsInputRef } from 'packages/app/src' import { useEffect, useRef, useState } from 'react' import { Circle } from 'tamagui' import { useResetWalletDevMenu } from '../utils/resetWallet' @@ -15,17 +17,20 @@ import { useResetWalletDevMenu } from '../utils/resetWallet' export default function Authenticate() { useResetWalletDevMenu() + const toast = useToastController() const secureUnlock = useSecureUnlock() + const biometricsType = useBiometricsType() const pinInputRef = useRef(null) const [isInitializingAgent, setIsInitializingAgent] = useState(false) const isLoading = secureUnlock.state === 'acquired-wallet-key' || (secureUnlock.state === 'locked' && secureUnlock.isUnlocking) + // biome-ignore lint/correctness/useExhaustiveDependencies: canTryUnlockingUsingBiometrics not needed useEffect(() => { if (secureUnlock.state === 'locked' && secureUnlock.canTryUnlockingUsingBiometrics) { secureUnlock.tryUnlockingUsingBiometrics() } - }, [secureUnlock]) + }, [secureUnlock.state]) useEffect(() => { if (secureUnlock.state !== 'acquired-wallet-key') return @@ -62,6 +67,18 @@ export default function Authenticate() { void SplashScreen.hideAsync() + const unlockUsingBiometrics = async () => { + if (secureUnlock.state === 'locked') { + secureUnlock.tryUnlockingUsingBiometrics() + } else { + toast.show('You PIN is required to unlock the app', { + customData: { + preset: 'danger', + }, + }) + } + } + const unlockUsingPin = async (pin: string) => { if (secureUnlock.state !== 'locked') return await secureUnlock.unlockUsingPin(pin) @@ -82,7 +99,9 @@ export default function Authenticate() { ref={pinInputRef} pinLength={6} onPinComplete={unlockUsingPin} + onBiometricsTap={unlockUsingBiometrics} useNativeKeyboard={false} + biometricsType={biometricsType ?? 'fingerprint'} /> ) diff --git a/apps/easypid/src/features/onboarding/screens/id-card-pin.tsx b/apps/easypid/src/features/onboarding/screens/id-card-pin.tsx index 47243984..1e1fd133 100644 --- a/apps/easypid/src/features/onboarding/screens/id-card-pin.tsx +++ b/apps/easypid/src/features/onboarding/screens/id-card-pin.tsx @@ -2,6 +2,7 @@ import { IdCard, Paragraph, PinPad, PinValues, Stack, XStack, YStack } from '@pa import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react' import type { TextInput } from 'react-native' +import { useHaptics } from 'packages/app/src/hooks/useHaptics' import germanIssuerImage from '../../../../assets/german-issuer-image.png' import pidBackgroundImage from '../../../../assets/pid-background.png' @@ -14,6 +15,7 @@ const pinLength = 6 export const OnboardingIdCardPinEnter = forwardRef(({ goToNextStep }: OnboardingIdCardPinEnterProps, ref) => { const [pin, setPin] = useState('') const inputRef = useRef(null) + const { withHaptics } = useHaptics() useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), @@ -35,7 +37,7 @@ export const OnboardingIdCardPinEnter = forwardRef(({ goToNextStep }: Onboarding } } - const onPressPinNumber = (character: PinValues) => { + const onPressPinNumber = withHaptics((character: PinValues) => { if (character === PinValues.Backspace) { setPin((pin) => pin.slice(0, pin.length - 1)) return @@ -55,7 +57,7 @@ export const OnboardingIdCardPinEnter = forwardRef(({ goToNextStep }: Onboarding return newPin }) - } + }) return ( diff --git a/apps/easypid/src/features/onboarding/screens/pin.tsx b/apps/easypid/src/features/onboarding/screens/pin.tsx index 4ba5ce19..411ea951 100644 --- a/apps/easypid/src/features/onboarding/screens/pin.tsx +++ b/apps/easypid/src/features/onboarding/screens/pin.tsx @@ -1,4 +1,6 @@ -import { PinDotsInput, type PinDotsInputRef, YStack } from '@package/ui' +import { YStack } from '@package/ui' +import { PinDotsInput } from 'packages/app/src' +import type { PinDotsInputRef } from 'packages/app/src' import React, { useRef, useState } from 'react' export interface OnboardingPinEnterProps { diff --git a/apps/easypid/src/features/share/slides/PinSlide.tsx b/apps/easypid/src/features/share/slides/PinSlide.tsx index 0018bfa7..663947d4 100644 --- a/apps/easypid/src/features/share/slides/PinSlide.tsx +++ b/apps/easypid/src/features/share/slides/PinSlide.tsx @@ -1,5 +1,5 @@ -import { usePushToWallet, useWizard } from '@package/app' -import { Heading, Paragraph, PinDotsInput, type PinDotsInputRef, YStack, useToastController } from '@package/ui' +import { PinDotsInput, type PinDotsInputRef, usePushToWallet, useWizard } from '@package/app' +import { Heading, Paragraph, YStack, useToastController } from '@package/ui' import { useRef, useState } from 'react' import type { PresentationRequestResult } from '../FunkeOpenIdPresentationNotificationScreen' diff --git a/apps/easypid/src/features/wallet/FunkePidConfirmationScreen.tsx b/apps/easypid/src/features/wallet/FunkePidConfirmationScreen.tsx index 93394696..6806c71d 100644 --- a/apps/easypid/src/features/wallet/FunkePidConfirmationScreen.tsx +++ b/apps/easypid/src/features/wallet/FunkePidConfirmationScreen.tsx @@ -1,4 +1,6 @@ -import { FlexPage, Heading, HeroIcons, PinDotsInput, type PinDotsInputRef, YStack } from '@package/ui' +import { FlexPage, Heading, HeroIcons, YStack } from '@package/ui' +import { PinDotsInput } from 'packages/app/src' +import type { PinDotsInputRef } from 'packages/app/src' import React, { forwardRef } from 'react' import { Circle } from 'tamagui' diff --git a/apps/easypid/src/hooks/useBiometricsType.tsx b/apps/easypid/src/hooks/useBiometricsType.tsx new file mode 100644 index 00000000..9a5454bb --- /dev/null +++ b/apps/easypid/src/hooks/useBiometricsType.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react' +import { Platform } from 'react-native' +import * as Keychain from 'react-native-keychain' + +export function useBiometricsType() { + // Set initial state based on platform (iOS has higher probability for face id) + const [biometryType, setBiometryType] = useState<'face' | 'fingerprint'>( + Platform.OS === 'ios' ? 'face' : 'fingerprint' + ) + + useEffect(() => { + async function checkBiometryType() { + const supportedBiometryType = await Keychain.getSupportedBiometryType() + if (supportedBiometryType) { + setBiometryType(supportedBiometryType?.toLowerCase().includes('face') ? 'face' : 'fingerprint') + } + } + + checkBiometryType() + }, []) + + return biometryType +} diff --git a/packages/ui/src/components/PinDotsInput.tsx b/packages/app/src/components/PinDotsInput.tsx similarity index 83% rename from packages/ui/src/components/PinDotsInput.tsx rename to packages/app/src/components/PinDotsInput.tsx index f9e79234..668e3774 100644 --- a/packages/ui/src/components/PinDotsInput.tsx +++ b/packages/app/src/components/PinDotsInput.tsx @@ -9,14 +9,17 @@ import Animated, { Easing, } from 'react-native-reanimated' import { Circle, Input } from 'tamagui' -import { XStack, YStack } from '../base' -import { PinPad, PinValues } from './PinPad' +import { XStack, YStack } from '../../../ui/src/base' +import { PinPad, PinValues } from '../../../ui/src/components/PinPad' +import { useHaptics } from '../hooks' interface PinDotsInputProps { pinLength: number onPinComplete: (pin: string) => void isLoading?: boolean useNativeKeyboard?: boolean + onBiometricsTap?: () => void + biometricsType?: 'face' | 'fingerprint' } export interface PinDotsInputRef { @@ -28,9 +31,17 @@ export interface PinDotsInputRef { export const PinDotsInput = forwardRef( ( - { onPinComplete, pinLength, isLoading, useNativeKeyboard = true }: PinDotsInputProps, + { + onPinComplete, + pinLength, + isLoading, + useNativeKeyboard = true, + onBiometricsTap, + biometricsType, + }: PinDotsInputProps, ref: ForwardedRef ) => { + const { withHaptics, errorHaptic } = useHaptics() const [pin, setPin] = useState('') const inputRef = useRef(null) @@ -43,12 +54,13 @@ export const PinDotsInput = forwardRef( // Shake animation const startShakeAnimation = useCallback(() => { + errorHaptic() shakeAnimation.value = withRepeat( withSequence(...[10, -7.5, 5, -2.5, 0].map((toValue) => withTiming(toValue, { duration: 75 }))), 1, true ) - }, [shakeAnimation]) + }, [shakeAnimation, errorHaptic]) useEffect(() => { translationAnimations.forEach((animation, index) => { @@ -85,7 +97,7 @@ export const PinDotsInput = forwardRef( [startShakeAnimation] ) - const onPressPinNumber = (character: PinValues) => { + const onPressPinNumber = withHaptics((character: PinValues) => { if (character === PinValues.Backspace) { setPin((pin) => pin.slice(0, pin.length - 1)) return @@ -95,6 +107,11 @@ export const PinDotsInput = forwardRef( return } + if ([PinValues.Fingerprint, PinValues.FaceId].includes(character) && onBiometricsTap) { + onBiometricsTap() + return + } + setPin((currentPin) => { const newPin = currentPin + character @@ -105,7 +122,7 @@ export const PinDotsInput = forwardRef( return newPin }) - } + }) const onChangePin = (newPin: string) => { if (isLoading) return @@ -156,7 +173,12 @@ export const PinDotsInput = forwardRef( secureTextEntry /> ) : ( - + )} ) diff --git a/packages/app/src/components/index.ts b/packages/app/src/components/index.ts index de43b5a1..636e703a 100644 --- a/packages/app/src/components/index.ts +++ b/packages/app/src/components/index.ts @@ -15,3 +15,4 @@ export * from './FunkeCredentialCard' export * from './DeleteCredentialSheet' export * from './CardInfoLifecycle' export * from './CardWithAttributes' +export * from './PinDotsInput' diff --git a/packages/app/src/hooks/useHaptics.tsx b/packages/app/src/hooks/useHaptics.tsx index 8cba1e76..f96882a2 100644 --- a/packages/app/src/hooks/useHaptics.tsx +++ b/packages/app/src/hooks/useHaptics.tsx @@ -22,11 +22,9 @@ export function useHaptics() { }, []) const withHaptics = useCallback( - unknown>( - callback: T, - hapticType: HapticType = 'light' - ): ((...args: Parameters) => ReturnType) => { - return (...args) => { + // biome-ignore lint/suspicious/noExplicitAny: should work no matter what the callback returns + any>(callback: T, hapticType: HapticType = 'light'): T => { + return ((...args) => { switch (hapticType) { case 'heavy': heavyHaptic() @@ -40,8 +38,8 @@ export function useHaptics() { default: lightHaptic() } - return callback(...args) as ReturnType - } + return callback(...args) + }) as T }, [lightHaptic, heavyHaptic, successHaptic, errorHaptic] ) diff --git a/packages/app/src/provider/BackgroundLockProvider.tsx b/packages/app/src/provider/BackgroundLockProvider.tsx new file mode 100644 index 00000000..3a5677c7 --- /dev/null +++ b/packages/app/src/provider/BackgroundLockProvider.tsx @@ -0,0 +1,39 @@ +import type { PropsWithChildren } from 'react' + +import { useSecureUnlock } from '@package/secure-store/secure-wallet-key/SecureUnlockProvider' +import { useEffect, useRef } from 'react' +import React from 'react' +import { AppState, type AppStateStatus } from 'react-native' + +const BACKGROUND_TIME_THRESHOLD = 30000 // 30 seconds + +export function BackgroundLockProvider({ children }: PropsWithChildren) { + const secureUnlock = useSecureUnlock() + const backgroundTimeRef = useRef(null) + + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState === 'background' || nextAppState === 'inactive') { + backgroundTimeRef.current = new Date() + } else if (nextAppState === 'active') { + if (backgroundTimeRef.current) { + const timeInBackground = new Date().getTime() - backgroundTimeRef.current.getTime() + + if (timeInBackground > BACKGROUND_TIME_THRESHOLD && secureUnlock.state === 'unlocked') { + console.log('App was in background for more than 30 seconds, locking') + secureUnlock.lock() + } + backgroundTimeRef.current = null + } + } + } + + const subscription = AppState.addEventListener('change', handleAppStateChange) + + return () => { + subscription.remove() + } + }, [secureUnlock]) + + return <>{children} +} diff --git a/packages/app/src/provider/index.ts b/packages/app/src/provider/index.ts index d4893858..116890b6 100644 --- a/packages/app/src/provider/index.ts +++ b/packages/app/src/provider/index.ts @@ -2,3 +2,4 @@ export * from './Provider' export * from './ToastViewport' export * from './NoInternetToastProvider' export * from './ModalProvider' +export * from './BackgroundLockProvider' diff --git a/packages/app/src/utils/DeeplinkHandler.tsx b/packages/app/src/utils/DeeplinkHandler.tsx index f16b25f7..6521dda9 100644 --- a/packages/app/src/utils/DeeplinkHandler.tsx +++ b/packages/app/src/utils/DeeplinkHandler.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react' import type { ReactNode } from 'react' -import { InvitationQrTypes, type InvitationType } from '@package/agent' +import { InvitationQrTypes } from '@package/agent' import { useToastController } from '@package/ui' import { CommonActions } from '@react-navigation/native' import * as Linking from 'expo-linking' diff --git a/packages/ui/assets/FaceId.tsx b/packages/ui/assets/FaceId.tsx new file mode 100644 index 00000000..92882dbd --- /dev/null +++ b/packages/ui/assets/FaceId.tsx @@ -0,0 +1,46 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg' + +export const FaceIdIcon = ({ width = 24, height = 24, color = 'black', ...props }: SvgProps) => { + return ( + + + + + + + + + + + ) +} diff --git a/packages/ui/src/components/PinPad.tsx b/packages/ui/src/components/PinPad.tsx index de4558ca..3e09de75 100644 --- a/packages/ui/src/components/PinPad.tsx +++ b/packages/ui/src/components/PinPad.tsx @@ -1,6 +1,6 @@ -import { Text, useTheme } from 'tamagui' +import { Text } from 'tamagui' import { Stack, XStack, YStack } from '../base' -import { HeroIcons } from '../content' +import { CustomIcons, HeroIcons } from '../content' export enum PinValues { One = '1', @@ -12,10 +12,13 @@ export enum PinValues { Seven = '7', Eight = '8', Nine = '9', - Empty = '', Zero = '0', Backspace = 'backspace', + Empty = '', + Fingerprint = 'fingerprint', + FaceId = 'faceid', } + const letterMap: Record = { [PinValues.One]: '', [PinValues.Two]: 'abc', @@ -27,6 +30,8 @@ const letterMap: Record = { [PinValues.Eight]: 'tuv', [PinValues.Nine]: 'wxyz', [PinValues.Zero]: '', + [PinValues.Fingerprint]: '', + [PinValues.FaceId]: '', [PinValues.Empty]: '', [PinValues.Backspace]: '', } @@ -41,9 +46,12 @@ const PinNumber = ({ character, onPressPinNumber, disabled }: PinNumberProps) => fg={1} jc="center" ai="center" - backgroundColor={character === PinValues.Backspace ? '$grey-200' : '$white'} + backgroundColor={ + [PinValues.Backspace, PinValues.Fingerprint, PinValues.FaceId, PinValues.Empty].includes(character) + ? '$grey-200' + : '$white' + } pressStyle={{ opacity: 0.5, backgroundColor: '$grey-100' }} - opacity={character === PinValues.Empty ? 0 : 1} onPress={() => onPressPinNumber(character)} disabled={disabled} h="$6" @@ -53,6 +61,10 @@ const PinNumber = ({ character, onPressPinNumber, disabled }: PinNumberProps) => > {character === PinValues.Backspace ? ( + ) : character === PinValues.Fingerprint ? ( + + ) : character === PinValues.FaceId ? ( + ) : ( {/* NOTE: using fontSize $ values will crash on android due to an issue with react-native-reanimated (it seems the string value is sent to the native side, which shouldn't happen) */} @@ -71,15 +83,21 @@ const PinNumber = ({ character, onPressPinNumber, disabled }: PinNumberProps) => export interface PinPadProps { onPressPinNumber: (character: PinValues) => void + useBiometricsPad?: boolean + biometricsType?: 'face' | 'fingerprint' disabled?: boolean } -export const PinPad = ({ onPressPinNumber, disabled }: PinPadProps) => { +export const PinPad = ({ onPressPinNumber, useBiometricsPad, disabled, biometricsType }: PinPadProps) => { const pinValues = [ [PinValues.One, PinValues.Two, PinValues.Three], [PinValues.Four, PinValues.Five, PinValues.Six], [PinValues.Seven, PinValues.Eight, PinValues.Nine], - [PinValues.Empty, PinValues.Zero, PinValues.Backspace], + [ + useBiometricsPad ? (biometricsType === 'face' ? PinValues.FaceId : PinValues.Fingerprint) : PinValues.Empty, + PinValues.Zero, + PinValues.Backspace, + ], ] const pinNumbers = pinValues.map((rowItems, rowIndex) => ( diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 192f4c97..486e06c4 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -1,4 +1,3 @@ -export * from './PinDotsInput' export * from './ProgressHeader' export * from './OnboardingStepItem' export * from './IdCard' diff --git a/packages/ui/src/content/Icon.tsx b/packages/ui/src/content/Icon.tsx index 0a66a4c2..3d1772a7 100644 --- a/packages/ui/src/content/Icon.tsx +++ b/packages/ui/src/content/Icon.tsx @@ -73,6 +73,7 @@ import { } from 'react-native-heroicons/solid' import { ExclamationIcon } from '../../assets/Exclamation' +import { FaceIdIcon } from '../../assets/FaceId' import { styled } from 'tamagui' @@ -167,6 +168,7 @@ export const HeroIcons = { export const CustomIcons = { Exclamation: wrapLocalSvg(ExclamationIcon as React.ComponentType), + FaceId: wrapLocalSvg(FaceIdIcon as React.ComponentType), } export type CustomIconProps = SvgProps & { diff --git a/packages/ui/src/panels/FloatingSheet.tsx b/packages/ui/src/panels/FloatingSheet.tsx index 83e2dc41..6fb18350 100644 --- a/packages/ui/src/panels/FloatingSheet.tsx +++ b/packages/ui/src/panels/FloatingSheet.tsx @@ -34,7 +34,7 @@ export function FloatingSheet({ children, isOpen, setIsOpen, ...props }: Floatin enterStyle={{ opacity: 0 }} exitStyle={{ opacity: 0 }} /> - + {children}