diff --git a/apps/expo/app.config.js b/apps/expo/app.config.js index 43c11a06..553ecd87 100644 --- a/apps/expo/app.config.js +++ b/apps/expo/app.config.js @@ -34,10 +34,11 @@ const invitationSchemes = [ 'openid-initiate-issuance', 'openid-credential-offer', 'openid-vc', + 'openid4vp', 'didcomm', ] -const associatedDomains = ['paradym.id', 'dev.paradym.id'] +const associatedDomains = ['paradym.id', 'dev.paradym.id', 'aurora.paradym.id'] /** * @type {import('@expo/config-types').ExpoConfig} diff --git a/apps/expo/app/[...unmatched].tsx b/apps/expo/app/[...unmatched].tsx new file mode 100644 index 00000000..12e1c653 --- /dev/null +++ b/apps/expo/app/[...unmatched].tsx @@ -0,0 +1,16 @@ +import { usePathname, useGlobalSearchParams } from 'expo-router' + +// NOTE: for all unmatched routes we render null, as it's good chance that +// we got here due to deep-linking, and we already handle that somewhere else +export default () => { + const pathname = usePathname() + const searchParams = useGlobalSearchParams() + + // eslint-disable-next-line no-console + console.warn( + 'Landed on unmatched route (probably due to deeplinking in which case this is not an error)', + { pathname, searchParams } + ) + + return null +} diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index 402b15cf..6993d3d7 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -27,6 +27,11 @@ import { getSecureWalletKey } from '../utils/walletKeyStore' void SplashScreen.preventAutoHideAsync() +export const unstable_settings = { + // Ensure any route can link back to `/` + initialRouteName: 'index', +} + export default function HomeLayout() { const [fontLoaded] = useFonts({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -144,17 +149,7 @@ export default function HomeLayout() { - - {/** - * Workaround: - * The following screens are not rendered by the router. - * They are used to prevent the internal route to be executed. - * So now they are being redirected to the home screen. So the user will not see a 404. - **/} - - - - + - -} diff --git a/apps/expo/app/https/[...dummy].tsx b/apps/expo/app/https/[...dummy].tsx deleted file mode 100644 index 03783931..00000000 --- a/apps/expo/app/https/[...dummy].tsx +++ /dev/null @@ -1,5 +0,0 @@ -// Workaround: To prevent the routing of the deeplinks - -export default function Screen() { - return -} diff --git a/apps/expo/app/invitation/[id].tsx b/apps/expo/app/invitation/[id].tsx deleted file mode 100644 index 03783931..00000000 --- a/apps/expo/app/invitation/[id].tsx +++ /dev/null @@ -1,5 +0,0 @@ -// Workaround: To prevent the routing of the deeplinks - -export default function Screen() { - return -} diff --git a/apps/expo/app/notifications/didCommCredential.tsx b/apps/expo/app/notifications/didCommCredential.tsx deleted file mode 100644 index bf432544..00000000 --- a/apps/expo/app/notifications/didCommCredential.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { DidCommCredentialNotificationScreen } from 'app/features/notifications' - -export default function Screen() { - return ( - <> - - - ) -} diff --git a/apps/expo/app/notifications/didCommPresentation.tsx b/apps/expo/app/notifications/didCommPresentation.tsx deleted file mode 100644 index 8f4e6b32..00000000 --- a/apps/expo/app/notifications/didCommPresentation.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { DidCommPresentationNotificationScreen } from 'app/features/notifications' - -export default function Screen() { - return ( - <> - - - ) -} diff --git a/apps/expo/app/notifications/didcomm.tsx b/apps/expo/app/notifications/didcomm.tsx new file mode 100644 index 00000000..6a02663a --- /dev/null +++ b/apps/expo/app/notifications/didcomm.tsx @@ -0,0 +1,9 @@ +import { DidCommNotificationScreen } from 'app/features/notifications' + +export default function Screen() { + return ( + <> + + + ) +} diff --git a/apps/expo/package.json b/apps/expo/package.json index 096d79f1..f624fea4 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -1,6 +1,6 @@ { "name": "expo-app", - "version": "1.2.4", + "version": "1.3.0", "main": "expo-router/entry", "private": true, "scripts": { diff --git a/apps/expo/utils/DeeplinkHandler.tsx b/apps/expo/utils/DeeplinkHandler.tsx index 0fde0e51..d9cb5926 100644 --- a/apps/expo/utils/DeeplinkHandler.tsx +++ b/apps/expo/utils/DeeplinkHandler.tsx @@ -1,30 +1,65 @@ import type { ReactNode } from 'react' -import { QrTypes } from '@internal/agent' +import { InvitationQrTypes } from '@internal/agent' +import { useToastController } from '@internal/ui' +import { CommonActions } from '@react-navigation/native' import { useCredentialDataHandler } from 'app/hooks/useCredentialDataHandler' import * as Linking from 'expo-linking' +import { useNavigation } from 'expo-router' import { useEffect, useState } from 'react' interface DeeplinkHandlerProps { children: ReactNode } -const deeplinkSchemes = Object.values(QrTypes) +const deeplinkSchemes = Object.values(InvitationQrTypes) export const DeeplinkHandler = ({ children }: DeeplinkHandlerProps) => { - const url = Linking.useURL() - const [lastDeeplink, setLastDeeplink] = useState(null) const { handleCredentialData } = useCredentialDataHandler() + const toast = useToastController() + const navigation = useNavigation() - useEffect(() => { - if (!url || url === lastDeeplink) return + // TODO: I'm not sure if we need this? Or whether an useEffect without any deps is enough? + const [hasHandledInitialUrl, setHasHandledInitialUrl] = useState(false) + + function handleUrl(url: string) { + const isRecognizedDeeplink = deeplinkSchemes.some((scheme) => url.startsWith(scheme)) + + // Whenever a deeplink comes in, we reset the state. This is due to expo + // routing us always and we can't intercept that. It seems they are working on + // more control, but for now this is the cleanest approach + navigation.dispatch( + CommonActions.reset({ + routes: [{ key: 'index', name: 'index' }], + }) + ) // Ignore deeplinks that don't start with the schemes for credentials - if (!deeplinkSchemes.some((scheme) => url.startsWith(scheme))) return + if (isRecognizedDeeplink) { + void handleCredentialData(url).then((result) => { + if (!result.success) { + toast.show(result.error) + } + }) + } + } - setLastDeeplink(url) - void handleCredentialData(url) - }, [url]) + // NOTE: we use getInitialURL and the event listener over useURL as we don't know + // using that method whether the same url is opened multiple times. As we need to make + // sure to handle ALL incoming deeplinks (to prevent default expo-router behaviour) we + // handle them ourselves. On startup getInitialUrl will be called once. + useEffect(() => { + if (hasHandledInitialUrl) return + void Linking.getInitialURL().then((url) => { + if (url) handleUrl(url) + setHasHandledInitialUrl(true) + }) + }, [hasHandledInitialUrl]) + + useEffect(() => { + const eventListener = Linking.addEventListener('url', (event) => handleUrl(event.url)) + return () => eventListener.remove() + }, []) return <>{children} } diff --git a/packages/agent/src/hooks/useInboxNotifications.ts b/packages/agent/src/hooks/useInboxNotifications.ts index fc5abd22..84f4a8fc 100644 --- a/packages/agent/src/hooks/useInboxNotifications.ts +++ b/packages/agent/src/hooks/useInboxNotifications.ts @@ -128,7 +128,7 @@ export const useInboxNotifications = () => { createdAt: record.createdAt, contactLabel: metadata?.issuerName, notificationTitle: metadata?.credentialName ?? 'Credential', - } + } as const } else { const metadata = getDidCommProofExchangeDisplayMetadata(record) @@ -138,7 +138,7 @@ export const useInboxNotifications = () => { createdAt: record.createdAt, contactLabel: metadata?.verifierName, notificationTitle: metadata?.proofName ?? 'Data Request', - } + } as const } }) }, [proofExchangeRecords, credentialExchangeRecords]) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index c850d218..940d9d70 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -11,7 +11,7 @@ global.Buffer = Buffer export { initializeAgent, useAgent, AppAgent } from './agent' export * from './providers' -export * from './parsers' +export * from './invitation' export * from './display' export * from './hooks' export { diff --git a/packages/agent/src/invitation/fetchInvitation.ts b/packages/agent/src/invitation/fetchInvitation.ts new file mode 100644 index 00000000..8d260946 --- /dev/null +++ b/packages/agent/src/invitation/fetchInvitation.ts @@ -0,0 +1,97 @@ +import type { ParseInvitationResult } from './parsers' + +const errorResponse = (message: string) => { + return { + success: false, + error: message, + } as const +} + +export async function fetchInvitationDataUrl(dataUrl: string): Promise { + // If we haven't had a response after 10 seconds, we will handle as if the invitation is not valid. + const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort('timeout reached'), 10000) + + try { + // If we still don't know what type of invitation it is, we assume it is a URL that we need to fetch to retrieve the invitation. + const response = await fetch(dataUrl, { + headers: { + // for DIDComm out of band invitations we should include application/json + // but we are flexible and also want to support other types of invitations + // as e.g. the OpenID SIOP request is a signed encoded JWT string + Accept: 'application/json, text/plain, */*', + }, + }) + clearTimeout(timeout) + if (!response.ok) { + return errorResponse('Unable to retrieve invitation.') + } + + const contentType = response.headers.get('content-type') + if (contentType?.includes('application/json')) { + const json: unknown = await response.json() + return handleJsonResponse(json) + } else { + const text = await response.text() + return handleTextResponse(text) + } + } catch (error) { + clearTimeout(timeout) + return errorResponse('Unable to retrieve invitation.') + } +} + +function handleJsonResponse(json: unknown): ParseInvitationResult { + // We expect a JSON object + if (!json || typeof json !== 'object' || Array.isArray(json)) { + return errorResponse('Invitation not recognized.') + } + + if ('@type' in json) { + return { + success: true, + result: { + format: 'parsed', + type: 'didcomm', + data: json, + }, + } + } + + if ('credential_issuer' in json) { + return { + success: true, + result: { + format: 'parsed', + type: 'openid-credential-offer', + data: json, + }, + } + } + + return errorResponse('Invitation not recognized.') +} + +function handleTextResponse(text: string): ParseInvitationResult { + // If the text starts with 'ey' we assume it's a JWT and thus an OpenID authorization request + if (text.startsWith('ey')) { + return { + success: true, + result: { + format: 'parsed', + type: 'openid-authorization-request', + data: text, + }, + } + } + + // Otherwise we still try to parse it as JSON + try { + const json: unknown = JSON.parse(text) + return handleJsonResponse(json) + + // handel like above + } catch (error) { + return errorResponse('Invitation not recognized.') + } +} diff --git a/packages/agent/src/parsers.ts b/packages/agent/src/invitation/handler.ts similarity index 79% rename from packages/agent/src/parsers.ts rename to packages/agent/src/invitation/handler.ts index 10be1a53..74e3557c 100644 --- a/packages/agent/src/parsers.ts +++ b/packages/agent/src/invitation/handler.ts @@ -1,4 +1,4 @@ -import type { AppAgent } from './agent' +import type { AppAgent } from '../agent' import type { ConnectionRecord, CredentialStateChangedEvent, @@ -38,42 +38,40 @@ import { import { supportsIncomingMessageType } from '@credo-ts/core/build/utils/messageType' import { OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc' import { getHostNameFromUrl } from '@internal/utils' -import queryString from 'query-string' import { filter, firstValueFrom, merge, first, timeout } from 'rxjs' -import { setOpenId4VcCredentialMetadata } from './openid4vc/metadata' - -export enum QrTypes { - OPENID_INITIATE_ISSUANCE = 'openid-initiate-issuance://', - OPENID_CREDENTIAL_OFFER = 'openid-credential-offer://', - OPENID = 'openid://', - OPENID_VC = 'openid-vc://', - DIDCOMM = 'didcomm://', - HTTPS = 'https://', -} - -export const isOpenIdCredentialOffer = (url: string) => { - return ( - url.startsWith(QrTypes.OPENID_INITIATE_ISSUANCE) || - url.startsWith(QrTypes.OPENID_CREDENTIAL_OFFER) - ) -} - -export const isOpenIdPresentationRequest = (url: string) => { - return url.startsWith(QrTypes.OPENID) || url.startsWith(QrTypes.OPENID_VC) -} +import { setOpenId4VcCredentialMetadata } from '../openid4vc/metadata' export const receiveCredentialFromOpenId4VciOffer = async ({ agent, data, + uri, }: { agent: AppAgent - data: string + // Either data itself (the offer) or uri can be passed + data?: string + uri?: string }) => { - if (!isOpenIdCredentialOffer(data)) - throw new Error('URI does not start with OpenID issuance prefix.') + let offerUri = uri + + if (!offerUri && data) { + // FIXME: Credo only support credential offer string, but we already parsed it before. So we construct an offer here + // but in the future we need to support the parsed offer in Credo directly + offerUri = `openid-credential-offer://credential_offer=${encodeURIComponent( + JSON.stringify(data) + )}` + } else if (!offerUri) { + throw new Error('either data or uri must be provided') + } - const resolvedCredentialOffer = await agent.modules.openId4VcHolder.resolveCredentialOffer(data) + agent.config.logger.info(`Receiving openid uri ${offerUri}`, { + offerUri, + data, + uri, + }) + const resolvedCredentialOffer = await agent.modules.openId4VcHolder.resolveCredentialOffer( + offerUri + ) // FIXME: return credential_supported entry for credential so it's easy to store metadata const credentials = @@ -188,15 +186,32 @@ export const receiveCredentialFromOpenId4VciOffer = async ({ } export const getCredentialsForProofRequest = async ({ - data, agent, + data, + uri, }: { - data: string agent: AppAgent + // Either data or uri can be provided + data?: string + uri?: string }) => { - if (!isOpenIdPresentationRequest(data)) throw new Error('URI does not start with OpenID prefix.') + let requestUri = uri + + if (!requestUri && data) { + // FIXME: Credo only support request string, but we already parsed it before. So we construct an request here + // but in the future we need to support the parsed request in Credo directly + requestUri = `openid://request=${encodeURIComponent(data)}` + } else if (!requestUri) { + throw new Error('Either data or uri must be provided') + } + + agent.config.logger.info(`Receiving openid uri ${requestUri}`, { + data, + uri, + requestUri, + }) - const resolved = await agent.modules.openId4VcHolder.resolveSiopAuthorizationRequest(data) + const resolved = await agent.modules.openId4VcHolder.resolveSiopAuthorizationRequest(requestUri) if (!resolved.presentationExchange) { throw new Error('No presentation exchange found in authorization request.') @@ -262,9 +277,9 @@ export async function receiveOutOfBandInvitation( agent: AppAgent, invitation: OutOfBandInvitation ): Promise< - | { result: 'success'; credentialExchangeId: string } - | { result: 'success'; proofExchangeId: string } - | { result: 'error'; message: string } + | { success: true; id: string; type: 'credentialExchange' } + | { success: true; id: string; type: 'proofExchange' } + | { success: false; error: string } > { const requestMessages = invitation.getRequests() ?? [] @@ -273,8 +288,8 @@ export async function receiveOutOfBandInvitation( 'Message contains multiple requests. Invitation should only contain a single request.' agent.config.logger.error(message) return { - result: 'error', - message, + success: false, + error: message, } } @@ -284,8 +299,8 @@ export async function receiveOutOfBandInvitation( if (!invitation.handshakeProtocols || invitation.handshakeProtocols.length === 0) { agent.config.logger.error('No requests and no handshake protocols found in invitation.') return { - result: 'error', - message: 'Invalid invitation.', + success: false, + error: 'Invalid invitation.', } } } @@ -302,8 +317,8 @@ export async function receiveOutOfBandInvitation( if (!isValidRequestMessage) { agent.config.logger.error('Message request is not from supported protocol.') return { - result: 'error', - message: 'Invalid invitation.', + success: false, + error: 'Invalid invitation.', } } } @@ -342,8 +357,8 @@ export async function receiveOutOfBandInvitation( const receivedInvite = await agent.oob.findByReceivedInvitationId(invitation.id) if (receivedInvite) { return { - result: 'error', - message: 'Invitation has already been scanned.', + success: false, + error: 'Invitation has already been scanned.', } } @@ -359,8 +374,8 @@ export async function receiveOutOfBandInvitation( agent.config.logger.error(`Error while receiving invitation: ${error as string}`) return { - result: 'error', - message: 'Invalid invitation.', + success: false, + error: 'Invalid invitation.', } } @@ -370,13 +385,15 @@ export async function receiveOutOfBandInvitation( if (event.type === CredentialEventTypes.CredentialStateChanged) { return { - result: 'success', - credentialExchangeId: event.payload.credentialRecord.id, + success: true, + id: event.payload.credentialRecord.id, + type: 'credentialExchange', } } else if (event.type === ProofEventTypes.ProofStateChanged) { return { - result: 'success', - proofExchangeId: event.payload.proofRecord.id, + success: true, + id: event.payload.proofRecord.id, + type: 'proofExchange', } } } catch (error) { @@ -394,39 +411,13 @@ export async function receiveOutOfBandInvitation( } return { - result: 'error', - message: 'Invalid invitation.', + success: false, + error: 'Invalid invitation.', } } return { - result: 'error', - message: 'Invalid invitation.', - } -} - -export async function tryParseDidCommInvitation( - agent: AppAgent, - invitationUrl: string -): Promise { - try { - const parsedUrl = queryString.parseUrl(invitationUrl) - const updatedInvitationUrl = (parsedUrl.query['oobUrl'] as string | undefined) ?? invitationUrl - - // Try to parse the invitation as an DIDComm invitation. - // We can't know for sure, as it could be a shortened URL to a DIDComm invitation. - // So we use the parseMessage from AFJ and see if this returns a valid message. - // Parse invitation supports legacy connection invitations, oob invitations, and - // legacy connectionless invitations, and will all transform them into an OOB invitation. - const invitation = await agent.oob.parseInvitation(updatedInvitationUrl) - - agent.config.logger.debug(`Parsed didcomm invitation with id ${invitation.id}`) - return invitation - } catch (error) { - agent.config.logger.debug( - `Ignoring error during parsing of didcomm invitation, could be another type of invitation.` - ) - // We continue, as it could be there's other types of QRs besides DIDComm - return null + success: false, + error: 'Invalid invitation.', } } diff --git a/packages/agent/src/invitation/index.ts b/packages/agent/src/invitation/index.ts new file mode 100644 index 00000000..18f06abd --- /dev/null +++ b/packages/agent/src/invitation/index.ts @@ -0,0 +1,8 @@ +export { parseInvitationUrl, parseDidCommInvitation, InvitationQrTypes } from './parsers' +export { + receiveOutOfBandInvitation, + receiveCredentialFromOpenId4VciOffer, + storeCredential, + getCredentialsForProofRequest, + shareProof, +} from './handler' diff --git a/packages/agent/src/invitation/parsers.ts b/packages/agent/src/invitation/parsers.ts new file mode 100644 index 00000000..d210078b --- /dev/null +++ b/packages/agent/src/invitation/parsers.ts @@ -0,0 +1,164 @@ +import type { AppAgent } from '../agent' + +import { parseInvitationJson } from '@credo-ts/core/build/utils/parseInvitation' +import queryString from 'query-string' + +import { fetchInvitationDataUrl } from './fetchInvitation' + +export type ParseInvitationResult = + | { + success: true + result: ParsedInvitation + } + | { + success: false + error: string + } + +export type ParsedInvitation = { + type: 'didcomm' | 'openid-credential-offer' | 'openid-authorization-request' + format: 'url' | 'parsed' + data: string | Record +} + +export enum InvitationQrTypes { + OPENID_INITIATE_ISSUANCE = 'openid-initiate-issuance://', + OPENID_CREDENTIAL_OFFER = 'openid-credential-offer://', + // TODO: I think we should not support openid://, as we mainly support openid4vp + // But older requests do use openid:// I think (such as the DIIP dbc login) + // but I think we're going to move to just openid4p in the future + OPENID = 'openid://', + OPENID4VP = 'openid4vp://', + OPENID_VC = 'openid-vc://', + DIDCOMM = 'didcomm://', + HTTPS = 'https://', +} + +export const isOpenIdCredentialOffer = (url: string) => { + if ( + url.startsWith(InvitationQrTypes.OPENID_INITIATE_ISSUANCE) || + url.startsWith(InvitationQrTypes.OPENID_CREDENTIAL_OFFER) + ) { + return true + } + + if (url.includes('credential_offer_uri=') || url.includes('credential_offer=')) { + return true + } + + return false +} + +export const isOpenIdPresentationRequest = (url: string) => { + if ( + url.startsWith(InvitationQrTypes.OPENID) || + url.startsWith(InvitationQrTypes.OPENID_VC) || + url.startsWith(InvitationQrTypes.OPENID4VP) + ) { + return true + } + + if (url.includes('request_uri=') || url.includes('request=')) { + return true + } + + return false +} + +export const isDidCommInvitation = (url: string) => { + if (url.startsWith(InvitationQrTypes.DIDCOMM)) { + return true + } + + if ( + url.includes('c_i=') || + url.includes('oob=') || + url.includes('oobUrl=') || + url.includes('d_m=') + ) { + return true + } + + return false +} + +export async function parseDidCommInvitation( + agent: AppAgent, + invitation: string | Record +) { + try { + if (typeof invitation === 'string') { + const parsedUrl = queryString.parseUrl(invitation) + const updatedInvitationUrl = (parsedUrl.query['oobUrl'] as string | undefined) ?? invitation + + // Try to parse the invitation as an DIDComm invitation. + // We can't know for sure, as it could be a shortened URL to a DIDComm invitation. + // So we use the parseMessage from AFJ and see if this returns a valid message. + // Parse invitation supports legacy connection invitations, oob invitations, and + // legacy connectionless invitations, and will all transform them into an OOB invitation. + const parsedInvitation = await agent.oob.parseInvitation(updatedInvitationUrl) + + agent.config.logger.debug(`Parsed didcomm invitation with id ${parsedInvitation.id}`) + return { + success: true, + result: parsedInvitation, + } as const + } + + return { + success: true, + result: parseInvitationJson(invitation), + } as const + } catch (error) { + return { + success: false, + error: 'Failed to parse invitation.', + } as const + } +} + +export async function parseInvitationUrl(invitationUrl: string): Promise { + if (isOpenIdCredentialOffer(invitationUrl)) { + return { + success: true, + result: { + format: 'url', + type: 'openid-credential-offer', + data: invitationUrl, + }, + } + } + + if (isOpenIdPresentationRequest(invitationUrl)) { + return { + success: true, + result: { + format: 'url', + type: 'openid-authorization-request', + data: invitationUrl, + }, + } + } + + if (isDidCommInvitation(invitationUrl)) { + return { + success: true, + result: { + format: 'url', + type: 'didcomm', + data: invitationUrl, + }, + } + } + + // If we can't detect the type of invitation from the URL, we will try to fetch the data from the URL + // and see if we can detect if based on the response + if (invitationUrl.startsWith('https://')) { + return fetchInvitationDataUrl(invitationUrl) + } + + return { + success: false, + error: 'Invitation not recognized.', + } +} diff --git a/packages/app/features/notifications/DidCommCredentialNotificationScreen.tsx b/packages/app/features/notifications/DidCommCredentialNotificationScreen.tsx index f990969c..a2724c89 100644 --- a/packages/app/features/notifications/DidCommCredentialNotificationScreen.tsx +++ b/packages/app/features/notifications/DidCommCredentialNotificationScreen.tsx @@ -1,25 +1,25 @@ import { useAcceptDidCommCredential, useAgent } from '@internal/agent' import { useToastController } from '@internal/ui' import React from 'react' -import { createParam } from 'solito' import { useRouter } from 'solito/router' import { CredentialNotificationScreen } from './components/CredentialNotificationScreen' -import { GettingCredentialInformationScreen } from './components/GettingCredentialInformationScreen' +import { GettingInformationScreen } from './components/GettingInformationScreen' -type Query = { credentialExchangeId: string } - -const { useParams } = createParam() +interface DidCommCredentialNotificationScreenProps { + credentialExchangeId: string +} -export function DidCommCredentialNotificationScreen() { +export function DidCommCredentialNotificationScreen({ + credentialExchangeId, +}: DidCommCredentialNotificationScreenProps) { const { agent } = useAgent() const router = useRouter() const toast = useToastController() - const { params } = useParams() const { acceptCredential, status, credentialExchange, attributes, display } = - useAcceptDidCommCredential(params.credentialExchangeId) + useAcceptDidCommCredential(credentialExchangeId) const pushToWallet = () => { router.back() @@ -27,7 +27,7 @@ export function DidCommCredentialNotificationScreen() { } if (!credentialExchange || !attributes || !display) { - return + return } const onCredentialAccept = async () => { diff --git a/packages/app/features/notifications/DidCommNotificationScreen.tsx b/packages/app/features/notifications/DidCommNotificationScreen.tsx new file mode 100644 index 00000000..66840940 --- /dev/null +++ b/packages/app/features/notifications/DidCommNotificationScreen.tsx @@ -0,0 +1,127 @@ +import { parseDidCommInvitation, useAgent, receiveOutOfBandInvitation } from '@internal/agent' +import { useToastController } from '@internal/ui' +import React, { useEffect, useState } from 'react' +import { createParam } from 'solito' +import { useRouter } from 'solito/router' + +import { DidCommCredentialNotificationScreen } from './DidCommCredentialNotificationScreen' +import { DidCommPresentationNotificationScreen } from './DidCommPresentationNotificationScreen' +import { GettingInformationScreen } from './components/GettingInformationScreen' + +// We can route to this page from an existing record +// but also from a new invitation. So we support quite some +// params and we differ flow based on the params provided +// but this means we can have a single entrypoint and don't have to +// first route to a generic invitation page sometimes and then route +// to a credential or presentation screen when we have more information +type Query = { + invitation?: string + invitationUrl?: string + proofExchangeId?: string + credentialExchangeId?: string +} + +const { useParams } = createParam() + +export function DidCommNotificationScreen() { + const { agent } = useAgent() + const { params } = useParams() + const toast = useToastController() + const router = useRouter() + + const [isLoadingInvitation, setIsLoadingInvitation] = useState(false) + const [notification, setNotification] = useState( + params.credentialExchangeId + ? ({ type: 'credentialExchange', id: params.credentialExchangeId } as const) + : params.proofExchangeId + ? ({ type: 'proofExchange', id: params.proofExchangeId } as const) + : undefined + ) + + const pushToWallet = () => { + router.back() + router.push('/') + } + + useEffect(() => { + async function handleInvitation() { + if (isLoadingInvitation) return + + try { + const invitation = params.invitation + ? (JSON.parse(decodeURIComponent(params.invitation)) as Record) + : params.invitationUrl + ? decodeURIComponent(params.invitationUrl) + : undefined + + if (!invitation) return + setIsLoadingInvitation(true) + + const parseResult = await parseDidCommInvitation(agent, invitation) + if (!parseResult.success) { + toast.show(parseResult.error) + pushToWallet() + return + } + + const receiveResult = await receiveOutOfBandInvitation(agent, parseResult.result) + if (!receiveResult.success) { + toast.show(receiveResult.error) + pushToWallet() + return + } + + // We now know the type of the invitation + setNotification({ + id: receiveResult.id, + type: receiveResult.type, + }) + } catch (error: unknown) { + agent.config.logger.error('Error parsing invitation', { + error, + }) + toast.show('Error parsing invitation') + pushToWallet() + } + + setIsLoadingInvitation(false) + } + + void handleInvitation() + }, [params.invitation, params.invitationUrl]) + + // We were routed here without any notification + if ( + !params.credentialExchangeId && + !params.proofExchangeId && + !params.invitation && + !params.invitationUrl + ) { + // eslint-disable-next-line no-console + console.error( + 'One of credentialExchangeId, proofExchangeId, invitation or invitationUrl is required when navigating to DidCommNotificationScreen.' + ) + pushToWallet() + return null + } + + if (!notification) { + return + } + + // NOTE: suspense would be a great fit here as we'd be able to render the component + // already, but show the while the data inside the + // component is loading + if (notification.type === 'credentialExchange') { + return + } + + if (notification.type === 'proofExchange') { + return + } + + // eslint-disable-next-line no-console + console.error('Unknown notification type on DidCommNotificationScreen', notification) + pushToWallet() + return null +} diff --git a/packages/app/features/notifications/DidCommPresentationNotificationScreen.tsx b/packages/app/features/notifications/DidCommPresentationNotificationScreen.tsx index 9899c433..7ef87413 100644 --- a/packages/app/features/notifications/DidCommPresentationNotificationScreen.tsx +++ b/packages/app/features/notifications/DidCommPresentationNotificationScreen.tsx @@ -1,24 +1,25 @@ import { useAcceptDidCommPresentation, useAgent } from '@internal/agent' -import { useToastController, Spinner, Page, Paragraph } from '@internal/ui' +import { useToastController } from '@internal/ui' import React from 'react' -import { createParam } from 'solito' import { useRouter } from 'solito/router' +import { GettingInformationScreen } from './components/GettingInformationScreen' import { PresentationNotificationScreen } from './components/PresentationNotificationScreen' -type Query = { proofExchangeId: string } - -const { useParams } = createParam() +interface DidCommPresentationNotificationScreenProps { + proofExchangeId: string +} -export function DidCommPresentationNotificationScreen() { +export function DidCommPresentationNotificationScreen({ + proofExchangeId, +}: DidCommPresentationNotificationScreenProps) { const { agent } = useAgent() const router = useRouter() const toast = useToastController() - const { params } = useParams() const { acceptPresentation, proofExchange, status, submission, verifierName } = - useAcceptDidCommPresentation(params.proofExchangeId) + useAcceptDidCommPresentation(proofExchangeId) const pushToWallet = () => { router.back() @@ -26,14 +27,7 @@ export function DidCommPresentationNotificationScreen() { } if (!submission || !proofExchange) { - return ( - - - - Getting verification information - - - ) + return } const onProofAccept = () => { diff --git a/packages/app/features/notifications/NotificationInboxScreen.tsx b/packages/app/features/notifications/NotificationInboxScreen.tsx index 9bcf1960..c7f5384d 100644 --- a/packages/app/features/notifications/NotificationInboxScreen.tsx +++ b/packages/app/features/notifications/NotificationInboxScreen.tsx @@ -32,14 +32,14 @@ export function NotificationInboxScreen() { onPress={() => { if (notification.type === 'CredentialRecord') { push({ - pathname: '/notifications/didCommCredential', + pathname: '/notifications/didcomm', query: { credentialExchangeId: notification.id, }, }) } else { push({ - pathname: '/notifications/didCommPresentation', + pathname: '/notifications/didcomm', query: { proofExchangeId: notification.id, }, diff --git a/packages/app/features/notifications/OpenIdCredentialNotificationScreen.tsx b/packages/app/features/notifications/OpenIdCredentialNotificationScreen.tsx index 67b9b83a..ddd34c59 100644 --- a/packages/app/features/notifications/OpenIdCredentialNotificationScreen.tsx +++ b/packages/app/features/notifications/OpenIdCredentialNotificationScreen.tsx @@ -12,17 +12,17 @@ import { createParam } from 'solito' import { useRouter } from 'solito/router' import { CredentialNotificationScreen } from './components/CredentialNotificationScreen' -import { GettingCredentialInformationScreen } from './components/GettingCredentialInformationScreen' +import { GettingInformationScreen } from './components/GettingInformationScreen' -type Query = { uri: string } +type Query = { uri?: string; data?: string } -const { useParam } = createParam() +const { useParams } = createParam() export function OpenIdCredentialNotificationScreen() { const { agent } = useAgent() const router = useRouter() const toast = useToastController() - const [uri] = useParam('uri') + const { params } = useParams() const [credentialRecord, setCredentialRecord] = useState() const [isStoring, setIsStoring] = useState(false) @@ -33,27 +33,28 @@ export function OpenIdCredentialNotificationScreen() { } useEffect(() => { - const requestCredential = async (uri: string) => { + const requestCredential = async (params: Query) => { try { const credentialRecord = await receiveCredentialFromOpenId4VciOffer({ agent, - data: decodeURIComponent(uri), + data: params.data, + uri: params.uri, }) setCredentialRecord(credentialRecord) - } catch (e) { - agent.config.logger.error("Couldn't receive credential from OpenID4VCI offer", { - error: e as unknown, + } catch (e: unknown) { + agent.config.logger.error(`Couldn't receive credential from OpenID4VCI offer`, { + error: e, }) toast.show('Credential information could not be extracted.') pushToWallet() } } - if (uri) void requestCredential(uri) - }, [uri]) + void requestCredential(params) + }, [params]) if (!credentialRecord) { - return + return } const onCredentialAccept = async () => { diff --git a/packages/app/features/notifications/OpenIdPresentationNotificationScreen.tsx b/packages/app/features/notifications/OpenIdPresentationNotificationScreen.tsx index 8ea47a2a..e28c4646 100644 --- a/packages/app/features/notifications/OpenIdPresentationNotificationScreen.tsx +++ b/packages/app/features/notifications/OpenIdPresentationNotificationScreen.tsx @@ -4,22 +4,23 @@ import { useAgent, formatDifPexCredentialsForRequest, } from '@internal/agent' -import { useToastController, Spinner, Page, Paragraph } from '@internal/ui' +import { useToastController } from '@internal/ui' import React, { useEffect, useState, useMemo } from 'react' import { createParam } from 'solito' import { useRouter } from 'solito/router' +import { GettingInformationScreen } from './components/GettingInformationScreen' import { PresentationNotificationScreen } from './components/PresentationNotificationScreen' -type Query = { uri: string } +type Query = { uri?: string; data?: string } -const { useParam } = createParam() +const { useParams } = createParam() export function OpenIdPresentationNotificationScreen() { const { agent } = useAgent() const router = useRouter() const toast = useToastController() - const [uri] = useParam('uri') + const { params } = useParams() // TODO: update to useAcceptOpenIdPresentation const [credentialsForRequest, setCredentialsForRequest] = @@ -40,31 +41,29 @@ export function OpenIdPresentationNotificationScreen() { } useEffect(() => { - if (!uri) return - - getCredentialsForProofRequest({ agent, data: decodeURIComponent(uri) }) - .then((r) => { - setCredentialsForRequest(r) - }) - .catch((e) => { + async function handleRequest() { + try { + const credentialsForRequest = await getCredentialsForProofRequest({ + agent, + data: params.data, + uri: params.uri, + }) + setCredentialsForRequest(credentialsForRequest) + } catch (error: unknown) { toast.show('Presentation information could not be extracted.') agent.config.logger.error('Error getting credentials for request', { - error: e as unknown, + error, }) pushToWallet() - }) - }, [uri]) + } + } + + void handleRequest() + }, [params]) if (!submission || !credentialsForRequest) { - return ( - - - - Getting verification information - - - ) + return } const onProofAccept = () => { diff --git a/packages/app/features/notifications/components/GettingCredentialInformationScreen.tsx b/packages/app/features/notifications/components/GettingInformationScreen.tsx similarity index 63% rename from packages/app/features/notifications/components/GettingCredentialInformationScreen.tsx rename to packages/app/features/notifications/components/GettingInformationScreen.tsx index 74ed6732..16519ac5 100644 --- a/packages/app/features/notifications/components/GettingCredentialInformationScreen.tsx +++ b/packages/app/features/notifications/components/GettingInformationScreen.tsx @@ -1,6 +1,10 @@ import { Page, Paragraph, Spinner } from '@internal/ui' -export function GettingCredentialInformationScreen() { +interface GettingInformationScreenProps { + type: 'credential' | 'presentation' | 'invitation' +} + +export function GettingInformationScreen({ type }: GettingInformationScreenProps) { return ( - Getting credential information + Getting {type} information ) diff --git a/packages/app/features/notifications/index.ts b/packages/app/features/notifications/index.ts index d7eb1647..13a48f99 100644 --- a/packages/app/features/notifications/index.ts +++ b/packages/app/features/notifications/index.ts @@ -3,3 +3,4 @@ export * from './DidCommCredentialNotificationScreen' export * from './OpenIdPresentationNotificationScreen' export * from './DidCommPresentationNotificationScreen' export * from './NotificationInboxScreen' +export * from './DidCommNotificationScreen' diff --git a/packages/app/features/scan/ScanScreen.tsx b/packages/app/features/scan/ScanScreen.tsx index aca11974..45c2a4f6 100644 --- a/packages/app/features/scan/ScanScreen.tsx +++ b/packages/app/features/scan/ScanScreen.tsx @@ -1,7 +1,6 @@ import { QrScanner } from '@internal/scanner' import { Page, Spinner, Paragraph } from '@internal/ui' -import { sleep } from '@tanstack/query-core/build/lib/utils' -import * as Haptics from 'expo-haptics' +import { useIsFocused } from '@react-navigation/native' import React, { useState } from 'react' import { useRouter } from 'solito/router' @@ -18,29 +17,27 @@ export function QrScannerScreen() { const [isProcessing, setIsProcessing] = useState(false) const [isLoading, setIsLoading] = useState(false) + const isFocused = useIsFocused() const onScan = async (scannedData: string) => { - if (isProcessing) return + if (isProcessing || !isFocused) return setIsProcessing(true) setIsLoading(true) const result = await handleCredentialData(scannedData) - - if (result.result === 'error') { + if (!result.success) { const isUnsupportedUrl = unsupportedUrlPrefixes.find((x) => scannedData.includes(x)) setHelpText( isUnsupportedUrl ? 'This QR-code is not supported yet. Try scanning a different one.' - : result.message - ? result.message + : result.error + ? result.error : 'Invalid QR code. Try scanning a different one.' ) - - void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) setIsLoading(false) } - await sleep(5000) + await new Promise((resolve) => setTimeout(resolve, 5000)) setHelpText('') setIsLoading(false) setIsProcessing(false) diff --git a/packages/app/hooks/useCredentialDataHandler.tsx b/packages/app/hooks/useCredentialDataHandler.tsx index 579eb03a..a8896b3f 100644 --- a/packages/app/hooks/useCredentialDataHandler.tsx +++ b/packages/app/hooks/useCredentialDataHandler.tsx @@ -1,93 +1,74 @@ -import { - isOpenIdCredentialOffer, - isOpenIdPresentationRequest, - receiveOutOfBandInvitation, - tryParseDidCommInvitation, - useAgent, -} from '@internal/agent' +import { parseInvitationUrl } from '@internal/agent' import * as Haptics from 'expo-haptics' import { useRouter } from 'solito/router' -type CredentialDataOutputResult = - | { - result: 'success' - } - | { - result: 'error' - message: string - } - export const useCredentialDataHandler = () => { const { push } = useRouter() - const { agent } = useAgent() - const handleCredentialData = async (dataUrl: string): Promise => { - if (isOpenIdCredentialOffer(dataUrl)) { + const handleCredentialData = async (dataUrl: string) => { + const parseResult = await parseInvitationUrl(dataUrl) + + if (!parseResult.success) { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) + return parseResult + } + + const invitationData = parseResult.result + if (invitationData.type === 'openid-credential-offer') { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) push({ pathname: '/notifications/openIdCredential', query: { - uri: encodeURIComponent(dataUrl), + uri: + invitationData.format === 'url' + ? encodeURIComponent(invitationData.data as string) + : undefined, + data: + invitationData.format === 'parsed' + ? encodeURIComponent(JSON.stringify(invitationData.data)) + : undefined, }, }) - - return { - result: 'success', - } - } else if (isOpenIdPresentationRequest(dataUrl)) { + return { success: true } as const + } else if (invitationData.type === 'openid-authorization-request') { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) push({ pathname: '/notifications/openIdPresentation', query: { - uri: encodeURIComponent(dataUrl), + uri: + invitationData.format === 'url' + ? encodeURIComponent(invitationData.data as string) + : undefined, + data: + invitationData.format === 'parsed' + ? encodeURIComponent(invitationData.data as string) + : undefined, }, }) - - return { - result: 'success', - } - } - - // If it is not an OpenID invitation, we assume the data is a DIDComm invitation. - // We can't know for sure, as it could be a shortened URL to a DIDComm invitation. - // So we use the parseMessage from AFJ and see if this returns a valid message. - // Parse invitation supports legacy connection invitations, oob invitations, and - // legacy connectionless invitations, and will all transform them into an OOB invitation. - const invitation = await tryParseDidCommInvitation(agent, dataUrl) - - if (invitation) { + return { success: true } as const + } else if (invitationData.type === 'didcomm') { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) - const result = await receiveOutOfBandInvitation(agent, invitation) - - // Error - if (result.result === 'error') { - return result - } - - // Credential exchange - if ('credentialExchangeId' in result) { - push({ - pathname: '/notifications/didCommCredential', - query: { - credentialExchangeId: result.credentialExchangeId, - }, - }) - } - // Proof Exchange - else if ('proofExchangeId' in result) { - push({ - pathname: '/notifications/didCommPresentation', - query: { - proofExchangeId: result.proofExchangeId, - }, - }) - } + push({ + pathname: '/notifications/didcomm', + query: { + invitation: + invitationData.format === 'parsed' + ? encodeURIComponent(JSON.stringify(invitationData.data)) + : undefined, + invitationUrl: + invitationData.format === 'url' + ? encodeURIComponent(invitationData.data as string) + : undefined, + }, + }) + return { success: true } as const } + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) return { - result: 'error', - message: 'QR Code not recognized.', - } + success: false, + error: 'Invitation not recognized.', + } as const } return {