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}