Skip to content

Commit

Permalink
feat: manual biometrics and background lock (#201)
Browse files Browse the repository at this point in the history
Signed-off-by: Jan <[email protected]>
Signed-off-by: Timo Glastra <[email protected]>
Co-authored-by: Timo Glastra <[email protected]>
  • Loading branch information
janrtvld and TimoGlastra authored Nov 5, 2024
1 parent 56e64ac commit a5f3cc5
Show file tree
Hide file tree
Showing 20 changed files with 214 additions and 38 deletions.
1 change: 0 additions & 1 deletion apps/easypid/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion apps/easypid/src/app/(app)/pinConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
10 changes: 6 additions & 4 deletions apps/easypid/src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -28,9 +28,11 @@ export default function RootLayout() {
},
}}
>
<NoInternetToastProvider>
<Slot />
</NoInternetToastProvider>
<BackgroundLockProvider>
<NoInternetToastProvider>
<Slot />
</NoInternetToastProvider>
</BackgroundLockProvider>
</ThemeProvider>
</SecureUnlockProvider>
</Provider>
Expand Down
23 changes: 21 additions & 2 deletions apps/easypid/src/app/authenticate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<PinDotsInputRef>(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
Expand Down Expand Up @@ -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)
Expand All @@ -82,7 +99,9 @@ export default function Authenticate() {
ref={pinInputRef}
pinLength={6}
onPinComplete={unlockUsingPin}
onBiometricsTap={unlockUsingBiometrics}
useNativeKeyboard={false}
biometricsType={biometricsType ?? 'fingerprint'}
/>
</FlexPage>
)
Expand Down
6 changes: 4 additions & 2 deletions apps/easypid/src/features/onboarding/screens/id-card-pin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -14,6 +15,7 @@ const pinLength = 6
export const OnboardingIdCardPinEnter = forwardRef(({ goToNextStep }: OnboardingIdCardPinEnterProps, ref) => {
const [pin, setPin] = useState('')
const inputRef = useRef<TextInput>(null)
const { withHaptics } = useHaptics()

useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
Expand All @@ -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
Expand All @@ -55,7 +57,7 @@ export const OnboardingIdCardPinEnter = forwardRef(({ goToNextStep }: Onboarding

return newPin
})
}
})

return (
<YStack fg={1} jc="space-between">
Expand Down
4 changes: 3 additions & 1 deletion apps/easypid/src/features/onboarding/screens/pin.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions apps/easypid/src/features/share/slides/PinSlide.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
23 changes: 23 additions & 0 deletions apps/easypid/src/hooks/useBiometricsType.tsx
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<PinDotsInputRef>
) => {
const { withHaptics, errorHaptic } = useHaptics()
const [pin, setPin] = useState('')
const inputRef = useRef<TextInput>(null)

Expand All @@ -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) => {
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -105,7 +122,7 @@ export const PinDotsInput = forwardRef(

return newPin
})
}
})

const onChangePin = (newPin: string) => {
if (isLoading) return
Expand Down Expand Up @@ -156,7 +173,12 @@ export const PinDotsInput = forwardRef(
secureTextEntry
/>
) : (
<PinPad onPressPinNumber={onPressPinNumber} disabled={isInLoadingState} />
<PinPad
onPressPinNumber={onPressPinNumber}
disabled={isInLoadingState}
useBiometricsPad={!!onBiometricsTap}
biometricsType={biometricsType}
/>
)}
</YStack>
)
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from './FunkeCredentialCard'
export * from './DeleteCredentialSheet'
export * from './CardInfoLifecycle'
export * from './CardWithAttributes'
export * from './PinDotsInput'
12 changes: 5 additions & 7 deletions packages/app/src/hooks/useHaptics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@ export function useHaptics() {
}, [])

const withHaptics = useCallback(
<T extends (...args: unknown[]) => unknown>(
callback: T,
hapticType: HapticType = 'light'
): ((...args: Parameters<T>) => ReturnType<T>) => {
return (...args) => {
// biome-ignore lint/suspicious/noExplicitAny: should work no matter what the callback returns
<T extends (...args: any[]) => any>(callback: T, hapticType: HapticType = 'light'): T => {
return ((...args) => {
switch (hapticType) {
case 'heavy':
heavyHaptic()
Expand All @@ -40,8 +38,8 @@ export function useHaptics() {
default:
lightHaptic()
}
return callback(...args) as ReturnType<T>
}
return callback(...args)
}) as T
},
[lightHaptic, heavyHaptic, successHaptic, errorHaptic]
)
Expand Down
39 changes: 39 additions & 0 deletions packages/app/src/provider/BackgroundLockProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<Date | null>(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}</>
}
1 change: 1 addition & 0 deletions packages/app/src/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './Provider'
export * from './ToastViewport'
export * from './NoInternetToastProvider'
export * from './ModalProvider'
export * from './BackgroundLockProvider'
2 changes: 1 addition & 1 deletion packages/app/src/utils/DeeplinkHandler.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading

0 comments on commit a5f3cc5

Please sign in to comment.