Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ui flow for offline sharing #211

Merged
merged 18 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/easypid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@hyperledger/indy-vdr-react-native": "catalog:",
"@package/agent": "workspace:*",
"@package/app": "workspace:*",
"@package/scanner": "workspace:*",
"@package/secure-store": "workspace:*",
"@package/ui": "workspace:*",
"@package/utils": "workspace:*",
Expand Down
5 changes: 3 additions & 2 deletions apps/easypid/src/app/(app)/(home)/scan.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { type CredentialDataHandlerOptions, QrScannerScreen } from '@package/app'
import { FunkeQrScannerScreen } from '@easypid/features/scan/FunkeQrScannerScreen'
import type { CredentialDataHandlerOptions } from '@package/app'

// When going form the scanner we want to replace (as we have the modal)
export const credentialDataHandlerOptions = {
routeMethod: 'replace',
} satisfies CredentialDataHandlerOptions

export default function Screen() {
return <QrScannerScreen credentialDataHandlerOptions={credentialDataHandlerOptions} />
return <FunkeQrScannerScreen credentialDataHandlerOptions={credentialDataHandlerOptions} />
}
6 changes: 6 additions & 0 deletions apps/easypid/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export default function AppLayout() {
gestureEnabled: false,
}}
/>
<Stack.Screen
name="notifications/offlinePresentation"
options={{
gestureEnabled: false,
}}
/>
<Stack.Screen name="credentials/index" options={headerNormalOptions} />
<Stack.Screen name="credentials/[id]/index" options={headerNormalOptions} />
<Stack.Screen name="credentials/[id]/attributes" options={headerNormalOptions} />
Expand Down
18 changes: 18 additions & 0 deletions apps/easypid/src/app/(app)/notifications/offlinePresentation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { FunkeMdocOfflineSharingScreen } from '@easypid/features/share/FunkeMdocOfflineSharingScreen'
import { useLocalSearchParams } from 'expo-router'

export default function Screen() {
const { sessionTranscript, deviceRequest, requestedAttributes } = useLocalSearchParams()

const sessionTranscriptArray = new Uint8Array(Buffer.from(sessionTranscript as string, 'base64'))
const deviceRequestArray = new Uint8Array(Buffer.from(deviceRequest as string, 'base64'))
const requestedAttributesArray = JSON.parse(requestedAttributes as string)

return (
<FunkeMdocOfflineSharingScreen
sessionTranscript={sessionTranscriptArray}
deviceRequest={deviceRequestArray}
requestedAttributes={requestedAttributesArray}
/>
)
}
10 changes: 9 additions & 1 deletion apps/easypid/src/features/proximity/mdocProximity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ const PERMISSIONS = [

export const requestMdocPermissions = async () => {
if (Platform.OS !== 'android') return
await PermissionsAndroid.requestMultiple(PERMISSIONS)
return await PermissionsAndroid.requestMultiple(PERMISSIONS)
}

export const checkMdocPermissions = async () => {
if (Platform.OS !== 'android') return

// We assume if you don't have the first permission, you don't have the others either
// As we can not check multiple at once
return await PermissionsAndroid.check(PERMISSIONS[1])
}

export const getMdocQrCode = async () => {
Expand Down
283 changes: 283 additions & 0 deletions apps/easypid/src/features/scan/FunkeQrScannerScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import { QrScanner } from '@package/scanner'
import {
AnimatedStack,
Heading,
HeroIcons,
Loader,
Page,
Paragraph,
Spinner,
Stack,
useSpringify,
useToastController,
} from '@package/ui'
import { useIsFocused } from '@react-navigation/native'
import { useRouter } from 'expo-router'
import React, { useEffect, useState } from 'react'
import QRCode from 'react-native-qrcode-svg'

import { type CredentialDataHandlerOptions, isAndroid, useCredentialDataHandler, useHaptics } from '@package/app'
import { Alert, Linking, Platform, useWindowDimensions } from 'react-native'
import { FadeIn, FadeOut, LinearTransition, useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

import { pidSchemes } from '@easypid/constants'
import easypidLogo from '../../../assets/easypid.png'
import { checkMdocPermissions, getMdocQrCode, requestMdocPermissions, waitForDeviceRequest } from '../proximity'

const unsupportedUrlPrefixes = ['_oob=']

interface QrScannerScreenProps {
credentialDataHandlerOptions?: CredentialDataHandlerOptions
}

export function FunkeQrScannerScreen({ credentialDataHandlerOptions }: QrScannerScreenProps) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's sort of ok with the deadline now, but i think it would have been better to split this into a generic scan/show component that can be used in both paradym and easypid and then add a showQrCode and exrract all the mdoc specific logic out of here.

const { back } = useRouter()
const { handleCredentialData } = useCredentialDataHandler()
const { bottom, top } = useSafeAreaInsets()
const toast = useToastController()
const isFocused = useIsFocused()

const [showMyQrCode, setShowMyQrCode] = useState(false)
const [helpText, setHelpText] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [qrCodeData, setQrCodeData] = useState<string>()
const [arePermissionsGranted, setArePermissionsGranted] = useState(false)

useEffect(() => {
void checkMdocPermissions().then((result) => {
console.log('checkMdocPermissions', result)
setArePermissionsGranted(!!result)
})
}, [])

useEffect(() => {
if (showMyQrCode) {
void getMdocQrCode().then(setQrCodeData)
} else {
setQrCodeData(undefined)
}
}, [showMyQrCode])

const onCancel = () => back()

const onScan = async (scannedData: string) => {
if (isProcessing || !isFocused) return
setIsProcessing(true)
setIsLoading(true)

const result = await handleCredentialData(scannedData, credentialDataHandlerOptions)
if (!result.success) {
const isUnsupportedUrl =
unsupportedUrlPrefixes.find((x) => scannedData.includes(x)) || result.error === 'invitation_type_not_allowed'
setHelpText(
isUnsupportedUrl
? 'This QR-code is not supported yet. Try scanning a different one.'
: result.message
? result.message
: 'Invalid QR code. Try scanning a different one.'
)
setIsLoading(false)
}

await new Promise((resolve) => setTimeout(resolve, 5000))
setHelpText('')
setIsLoading(false)
setIsProcessing(false)
}

const handleQrButtonPress = async () => {
if (Platform.OS !== 'android') {
toast.show('This feature is not supported on your OS yet.', { customData: { preset: 'warning' } })
return
}

if (arePermissionsGranted) {
setShowMyQrCode(true)
} else {
const permissions = await requestMdocPermissions()
if (!permissions) {
toast.show('Failed to request permissions.', { customData: { preset: 'danger' } })
return
}

// Check if any permission is in 'never_ask_again' state
const hasNeverAskAgain = Object.values(permissions).some((status) => status === 'never_ask_again')

if (hasNeverAskAgain) {
Alert.alert(
'Please enable required permissions in your phone settings',
'Sharing with QR-Code needs access to Bluetooth and Location.',
[
{
text: 'Open Settings',
onPress: () => Linking.openSettings(),
},
]
)
return
}
}
}

const animatedQrOverlayOpacity = useAnimatedStyle(
() => ({
opacity: withTiming(showMyQrCode ? 1 : 0, { duration: showMyQrCode ? 300 : 200 }),
}),
[showMyQrCode]
)

return (
<>
<QrScanner
onScan={(data) => {
void onScan(data)
}}
helpText={helpText}
/>
<Stack zi="$5" position="absolute" top={isAndroid() ? top : 0} right={0} bottom={0}>
<Stack
accessibilityRole="button"
aria-label={`Close QR ${showMyQrCode ? 'scanner' : 'screen'}`}
p="$4"
onPress={onCancel}
>
<HeroIcons.X size={24} color="$grey-400" />
</Stack>
</Stack>

<AnimatedStack bg="$black" style={animatedQrOverlayOpacity} pos="absolute" top={0} left={0} right={0} bottom={0}>
{showMyQrCode && <FunkeQrOverlay qrCodeData={qrCodeData} />}
</AnimatedStack>

{isLoading && (
<Page
position="absolute"
jc="center"
ai="center"
g="md"
enterStyle={{ opacity: 0, y: 50 }}
exitStyle={{ opacity: 0, y: -20 }}
y={0}
bg="$black"
opacity={0.7}
animation="lazy"
>
<Spinner variant="dark" />
<Paragraph variant="sub" color="$white" textAlign="center">
Loading invitation
</Paragraph>
</Page>
)}
<Page
bg="transparent"
position="absolute"
alignItems="center"
justifyContent="flex-end"
bottom={0}
left={0}
right={0}
pb={bottom * 2}
>
<AnimatedStack
alignItems="center"
layout={useSpringify(LinearTransition)}
onPress={handleQrButtonPress}
bg="$grey-100"
br="$12"
py="$2.5"
px="$5"
accessibilityRole="button"
aria-label={showMyQrCode ? 'Scan QR code' : 'Show my QR code'}
minWidth={150}
>
<AnimatedStack
key={showMyQrCode ? 'scan-qr-code' : 'show-my-qr-code'}
entering={FadeIn.delay(200).duration(200)}
exiting={FadeOut.duration(200)}
flexDirection="row"
ai="center"
gap="$2"
>
<Paragraph fontWeight="$semiBold" ai="center" ta="center" color="$grey-900">
{showMyQrCode ? 'Scan QR code' : 'Show my QR code'}
</Paragraph>
{showMyQrCode ? (
<HeroIcons.CameraFilled size={20} color="$grey-900" />
) : (
<HeroIcons.QrCode size={20} color="$grey-900" />
)}
</AnimatedStack>
</AnimatedStack>
</Page>
</>
)
}

function FunkeQrOverlay({ qrCodeData }: { qrCodeData?: string }) {
const { width } = useWindowDimensions()
const { bottom, top } = useSafeAreaInsets()
const { withHaptics } = useHaptics()
const { replace } = useRouter()

useEffect(() => {
if (qrCodeData) {
void waitForDeviceRequest().then((data) => {
if (data) {
// Take the Doc item that matches with the mdoc pid type
// Only support one doc item for now

const requestedNamespace = data.requestedItems[0][pidSchemes.msoMdocDoctypes[0]]
if (!requestedNamespace) throw new Error('Unsupported credential requested.')

pushToOfflinePresentation({
sessionTranscript: Buffer.from(data.sessionTranscript).toString('base64'),
deviceRequest: Buffer.from(data.deviceRequest).toString('base64'),
requestedAttributes: Object.entries(requestedNamespace).map(([key]) => key),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we only handle the requested namespaces (not attributes within?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It takes the requestedNamespace which is equal to the pid, and in there are the attributes requested.

})
return
}
})
}
}, [qrCodeData])

// Navigate to offline presentation route
const pushToOfflinePresentation = withHaptics(
(data: { sessionTranscript: string; deviceRequest: string; requestedAttributes: string[] }) =>
replace({
pathname: '/notifications/offlinePresentation',
params: {
...data,
requestedAttributes: JSON.stringify(data.requestedAttributes), // Add this change
},
})
)

return (
<Page bg="$black" ai="center" pt={isAndroid() ? top : 0} pb={bottom}>
<AnimatedStack pt="$8" maxWidth="90%" gap="$2" entering={FadeIn.duration(200).delay(300)}>
<Heading variant="h1" lineHeight={36} ta="center" dark>
Share with QR code
</Heading>
<Paragraph color="$grey-400">A verifier needs to scan your QR-Code.</Paragraph>
</AnimatedStack>
<AnimatedStack entering={FadeIn.duration(200).delay(300)} fg={1} pb="$12" jc="center">
{qrCodeData ? (
<Stack bg="$white" br="$8" p="$5">
<QRCode
logoBorderRadius={12}
logoMargin={4}
logo={easypidLogo}
size={Math.min(width * 0.75, 272)}
value={qrCodeData}
/>
</Stack>
) : (
<Loader variant="dark" />
)}
</AnimatedStack>
<Stack h="$4" />
</Page>
)
}
Loading