-
Notifications
You must be signed in to change notification settings - Fork 13
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
Changes from all commits
ab67118
b997e47
a9a9b23
a9ff753
b443831
99c2122
a04e5b0
6232f65
8f630a3
c474007
b530698
0445038
6ec0b14
271c15a
42cf711
081131d
e2a57a8
f17adf8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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} /> | ||
} |
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} | ||
/> | ||
) | ||
} |
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) { | ||
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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we only handle the requested namespaces (not attributes within?) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
) | ||
} |
There was a problem hiding this comment.
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.