diff --git a/app/container-imp.ts b/app/container-imp.ts index 3d9e05ec1..71860090d 100644 --- a/app/container-imp.ts +++ b/app/container-imp.ts @@ -32,9 +32,12 @@ import { DependencyContainer } from 'tsyringe' import { autoDisableRemoteLoggingIntervalInMinutes } from './src/constants' import { expirationOverrideInMinutes } from './src/helpers/utils' import Developer from './src/screens/Developer' +import PersonCredential from './src/screens/PersonCredential' +import PersonCredentialLoading from './src/screens/PersonCredentialLoading' import Preface from './src/screens/Preface' import Terms, { TermsVersion } from './src/screens/Terms' import { + BCDispatchAction, BCLocalStorageKeys, BCState, DismissPersonCredentialOffer, @@ -157,6 +160,29 @@ export class AppContainer implements Container { }) this._container.registerInstance(TOKENS.UTIL_OCA_RESOLVER, resolver) + this._container.registerInstance(TOKENS.CUSTOM_NOTIFICATION, { + component: PersonCredential, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onCloseAction: (dispatch?: React.Dispatch>) => { + if (dispatch) { + dispatch({ + type: BCDispatchAction.PERSON_CREDENTIAL_OFFER_DISMISSED, + payload: [{ personCredentialOfferDismissed: true }], + }) + } + }, + additionalStackItems: [ + { + component: PersonCredentialLoading, + name: 'PersonCredentialLoading', + }, + ], + pageTitle: 'PersonCredential.PageTitle', + title: 'PersonCredentialNotification.Title', + description: 'PersonCredentialNotification.Description', + buttonTitle: 'PersonCredentialNotification.ButtonTitle', + }) + this._container.registerInstance(TOKENS.UTIL_PROOF_TEMPLATE, useProofRequestTemplates) this._container.registerInstance(TOKENS.LOAD_STATE, async (dispatch: React.Dispatch>) => { const loadState = async (key: LocalStorageKeys | BCLocalStorageKeys, updateVal: (val: Type) => void) => { diff --git a/app/package.json b/app/package.json index bde559afb..8211baf51 100644 --- a/app/package.json +++ b/app/package.json @@ -61,11 +61,11 @@ "@formatjs/intl-relativetimeformat": "9.3.1", "@hyperledger/anoncreds-react-native": "0.2.2", "@hyperledger/aries-askar-react-native": "0.2.3", - "@hyperledger/aries-bifold-core": "1.0.0-alpha.284", - "@hyperledger/aries-bifold-remote-logs": "1.0.0-alpha.284", - "@hyperledger/aries-bifold-verifier": "1.0.0-alpha.284", - "@hyperledger/aries-oca": "1.0.0-alpha.284", - "@hyperledger/aries-react-native-attestation": "1.0.0-alpha.284", + "@hyperledger/aries-bifold-core": "1.0.0-alpha.285", + "@hyperledger/aries-bifold-remote-logs": "1.0.0-alpha.285", + "@hyperledger/aries-bifold-verifier": "1.0.0-alpha.285", + "@hyperledger/aries-oca": "1.0.0-alpha.285", + "@hyperledger/aries-react-native-attestation": "1.0.0-alpha.285", "@hyperledger/indy-vdr-react-native": "0.2.2", "@hyperledger/indy-vdr-shared": "0.2.2", "@react-native-async-storage/async-storage": "1.15.11", diff --git a/app/src/assets/img/personCredLoadingIcon.svg b/app/src/assets/img/personCredLoadingIcon.svg new file mode 100644 index 000000000..5052ece91 --- /dev/null +++ b/app/src/assets/img/personCredLoadingIcon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/components/PersonCredentialSpinner.tsx b/app/src/components/PersonCredentialSpinner.tsx new file mode 100644 index 000000000..5a137154f --- /dev/null +++ b/app/src/components/PersonCredentialSpinner.tsx @@ -0,0 +1,53 @@ +import { useTheme } from '@hyperledger/aries-bifold-core' +import React, { useEffect, useRef } from 'react' +import { View, StyleSheet, Animated } from 'react-native' + +import ActivityIndicator from '../assets/img/activity-indicator-circle.svg' +import EmptyWallet from '../assets/img/personCredLoadingIcon.svg' + +const PersonCredentialSpinner: React.FC = () => { + const { ColorPallet } = useTheme() + const rotationAnim = useRef(new Animated.Value(0)).current + const timing: Animated.TimingAnimationConfig = { + toValue: 1, + duration: 2000, + useNativeDriver: true, + } + const rotation = rotationAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }) + const style = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + animation: { + position: 'absolute', + zIndex: 1, + }, + }) + const displayOptions = { + fill: ColorPallet.notification.infoText, + } + const animatedCircleDisplayOptions = { + fill: ColorPallet.notification.infoText, + height: 250, + width: 250, + } + + useEffect(() => { + Animated.loop(Animated.timing(rotationAnim, timing)).start() + }, [rotationAnim]) + + return ( + + + + + + + ) +} + +export default PersonCredentialSpinner diff --git a/app/src/index.ts b/app/src/index.ts index fe9b886d2..f444ff75a 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -6,7 +6,6 @@ import { Agent, } from '@hyperledger/aries-bifold-core' import merge from 'lodash.merge' -import { ReducerAction } from 'react' import { Config } from 'react-native-config' import AddCredentialButton from './components/AddCredentialButton' @@ -23,11 +22,9 @@ import fr from './localization/fr' import ptBr from './localization/pt-br' import Developer from './screens/Developer' import { pages } from './screens/OnboardingPages' -import PersonCredential from './screens/PersonCredential' import Preface from './screens/Preface' import Splash from './screens/Splash' import Terms from './screens/Terms' -import { BCDispatchAction } from './store' import { defaultTheme as theme } from './theme' const localization = merge({}, translationResources, { @@ -50,22 +47,6 @@ const configuration: ConfigurationContext = { proofTemplateBaseUrl: Config.PROOF_TEMPLATE_URL, record: Record, settings: [], - customNotification: { - component: PersonCredential, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onCloseAction: (dispatch?: React.Dispatch>) => { - if (dispatch) { - dispatch({ - type: BCDispatchAction.PERSON_CREDENTIAL_OFFER_DISMISSED, - payload: [{ personCredentialOfferDismissed: true }], - }) - } - }, - pageTitle: 'PersonCredential.PageTitle', - title: 'PersonCredentialNotification.Title', - description: 'PersonCredentialNotification.Description', - buttonTitle: 'PersonCredentialNotification.ButtonTitle', - }, enableTours: true, showPreface: true, disableOnboardingSkip: true, diff --git a/app/src/screens/PersonCredential.tsx b/app/src/screens/PersonCredential.tsx index ca76bd0ed..042c16fbc 100644 --- a/app/src/screens/PersonCredential.tsx +++ b/app/src/screens/PersonCredential.tsx @@ -1,49 +1,17 @@ -import { ProofState, ProofExchangeRecord, CredentialState } from '@credo-ts/core' -import { useAgent, useProofByState, useCredentialByState } from '@credo-ts/react-hooks' -import { - useConfiguration, - useStore, - useTheme, - Button, - ButtonType, - testIdWithKey, - BifoldAgent, - Screens, - Stacks, - InfoTextBox, - Link, - BifoldError, - EventTypes as BifoldEventTypes, - TOKENS, - useContainer, -} from '@hyperledger/aries-bifold-core' +import { useAgent } from '@credo-ts/react-hooks' +import { useStore, useTheme, Button, ButtonType, testIdWithKey, Stacks, Link } from '@hyperledger/aries-bifold-core' import { useNavigation } from '@react-navigation/native' -import React, { useState, useCallback, useEffect, useRef } from 'react' +import React, { useState, useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { - DeviceEventEmitter, - EmitterSubscription, - StyleSheet, - Text, - View, - TouchableOpacity, - Linking, - Platform, - ScrollView, -} from 'react-native' +import { StyleSheet, Text, View, TouchableOpacity, Linking, Platform, ScrollView } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import Icon from 'react-native-vector-icons/MaterialIcons' import PersonIssuance1 from '../assets/img/PersonIssuance1.svg' import PersonIssuance2 from '../assets/img/PersonIssuance2.svg' -import LoadingIcon from '../components/LoadingIcon' -import { connectToIASAgent, authenticateWithServiceCard, WellKnownAgentDetails } from '../helpers/BCIDHelper' import { openLink } from '../helpers/utils' -import { AttestationEventTypes } from '../services/attestation' import { BCState } from '../store' -const attestationProofRequestWaitTimeout = 10000 - const links = { WhatIsPersonCredential: 'https://www2.gov.bc.ca/gov/content/governments/government-id/person-credential', WhereToUse: 'https://www2.gov.bc.ca/gov/content/governments/government-id/person-credential/where-person-cred', @@ -54,18 +22,9 @@ export default function PersonCredential() { const { agent } = useAgent() const [store] = useStore() const [appInstalled, setAppInstalled] = useState(false) - const [workflowInProgress, setWorkflowInProgress] = useState(false) const { ColorPallet, TextTheme } = useTheme() const { t } = useTranslation() - const { useAttestation } = useConfiguration() - const receivedCredentialOffers = useCredentialByState(CredentialState.OfferReceived) - const receivedProofRequests = useProofByState(ProofState.RequestReceived) const navigation = useNavigation() - const [remoteAgentDetails, setRemoteAgentDetails] = useState() - const { loading: attestationLoading } = useAttestation ? useAttestation() : { loading: false } - const [didCompleteAttestationProofRequest, setDidCompleteAttestationProofRequest] = useState(false) - const timer = useRef() - const logger = useContainer().resolve(TOKENS.UTIL_LOGGER) const styles = StyleSheet.create({ pageContainer: { @@ -117,7 +76,7 @@ export default function PersonCredential() { }, link: { ...TextTheme.normal, - color: workflowInProgress ? ColorPallet.grayscale.lightGrey : TextTheme.normal.color, + color: TextTheme.normal.color, textAlign: 'left', textDecorationLine: 'none', }, @@ -132,52 +91,10 @@ export default function PersonCredential() { return await Linking.canOpenURL('ca.bc.gov.id.servicescard://') } - // Use this function to accept the attestation proof request. - const acceptAttestationProofRequest = async (agent: BifoldAgent, proofRequest: ProofExchangeRecord) => { - logger.info('Attestation: selecting credentials for attestation proof request') - // This will throw if we don't have the necessary credentials - const credentials = await agent.proofs.selectCredentialsForRequest({ - proofRecordId: proofRequest.id, - }) - - logger.info('Attestation: accepting attestation proof request') - await agent.proofs.acceptRequest({ - proofRecordId: proofRequest.id, - proofFormats: credentials.proofFormats, - }) - - return true - } - - // when a person credential offer is received, show the - // offer screen to the user. - const goToCredentialOffer = (credentialId?: string) => { - navigation.getParent()?.navigate(Stacks.NotificationStack, { - screen: Screens.CredentialOffer, - params: { credentialId }, - }) - } - useEffect(() => { isBCServicesCardInstalled().then((result) => { setAppInstalled(result) }) - - const handleFailedAttestation = (error: BifoldError) => { - navigation.goBack() - DeviceEventEmitter.emit(BifoldEventTypes.ERROR_ADDED, error) - } - - const subscriptions = Array() - subscriptions.push(DeviceEventEmitter.addListener(AttestationEventTypes.FailedHandleProof, handleFailedAttestation)) - subscriptions.push(DeviceEventEmitter.addListener(AttestationEventTypes.FailedHandleOffer, handleFailedAttestation)) - subscriptions.push( - DeviceEventEmitter.addListener(AttestationEventTypes.FailedRequestCredential, handleFailedAttestation) - ) - - return () => { - subscriptions.forEach((subscription) => subscription.remove()) - } }, []) const acceptPersonCredentialOffer = useCallback(() => { @@ -185,116 +102,11 @@ export default function PersonCredential() { return } - // Start the Spinner and any text that indicates the workflow is in progress - // and the user needs to wait. - setWorkflowInProgress(true) - - connectToIASAgent(agent, store, t) - .then((remoteAgentDetails: WellKnownAgentDetails) => { - setRemoteAgentDetails(remoteAgentDetails) - - timer.current = setTimeout(() => { - if (!remoteAgentDetails || !remoteAgentDetails.connectionId) { - return - } - - const proofRequest = receivedProofRequests.find( - (proof) => proof.connectionId === remoteAgentDetails.connectionId - ) - - if (!proofRequest) { - // No proof from our IAS Agent to respond to, do nothing. - logger.info( - `Waited ${attestationProofRequestWaitTimeout / 1000}sec on attestation proof request, continuing` - ) - - setDidCompleteAttestationProofRequest(true) - } - }, attestationProofRequestWaitTimeout) - - logger.error(`Connected to IAS agent, connectionId: ${remoteAgentDetails?.connectionId}`) - }) - .catch((error) => { - logger.error(`Failed to connect to IAS agent, error: ${error.message}`) - }) + navigation.getParent()?.navigate(Stacks.NotificationStack, { + screen: 'PersonCredentialLoading', + }) }, []) - useEffect(() => { - // If we are fetching an attestation credential, do no yet have - // a remote connection ID to the IAS agent, or the agent is not - // initialized, do nothing. - if (attestationLoading || !remoteAgentDetails || !agent) { - return - } - - // We have an attestation credential and can respond to an - // attestation proof request. - const proofRequest = receivedProofRequests.find((proof) => proof.connectionId === remoteAgentDetails.connectionId) - if (!proofRequest) { - // No proof from our IAS Agent to respond to, do nothing. - return - } - - timer.current && clearTimeout(timer.current) - - if (!didCompleteAttestationProofRequest) { - acceptAttestationProofRequest(agent, proofRequest) - .then((status: boolean) => { - // We can unblock the workflow and proceed with - // authentication. - setDidCompleteAttestationProofRequest(status) - logger.info(`Accepted IAS attestation proof request with status: ${status}`) - }) - .catch((error) => { - setDidCompleteAttestationProofRequest(false) - logger.error(`Unable to accept IAS attestation proof request, error: ${error.message}`) - }) - } - }, [attestationLoading, receivedProofRequests, remoteAgentDetails, agent]) - - useEffect(() => { - if (!remoteAgentDetails || !remoteAgentDetails.legacyConnectionDid || !didCompleteAttestationProofRequest) { - return - } - - const cb = (status: boolean) => { - logger.info(`Service card authentication reported ${status}`) - // TODO(jl): Handle the case where the service card authentication fails for - // user reasons or otherwise. - if (!status) { - setDidCompleteAttestationProofRequest(false) - } - - setWorkflowInProgress(false) - } - - const { iasPortalUrl } = store.developer.environment - const { legacyConnectionDid } = remoteAgentDetails - - authenticateWithServiceCard(legacyConnectionDid, iasPortalUrl, cb) - .then(() => { - logger.error('Completed service card authentication successfully') - }) - .catch((error) => { - logger.error('Completed service card authentication with error, error: ', error.message) - }) - }, [remoteAgentDetails, didCompleteAttestationProofRequest]) - - useEffect(() => { - if (!remoteAgentDetails || !remoteAgentDetails.connectionId) { - return - } - - for (const credential of receivedCredentialOffers) { - if ( - credential.state == CredentialState.OfferReceived && - credential.connectionId === remoteAgentDetails.connectionId - ) { - goToCredentialOffer(credential.id) - } - } - }, [receivedCredentialOffers, remoteAgentDetails]) - const getBCServicesCardApp = useCallback(() => { setAppInstalled(true) const url = @@ -368,45 +180,35 @@ export default function PersonCredential() { {t('PersonCredential.CreatePersonCred')} - {workflowInProgress ? ( - - {t('PersonCredential.PleaseWait')} - - ) : null} {appInstalled ? ( + > ) : null} !workflowInProgress && openLink(links.WhatIsPersonCredential)} + onPress={() => openLink(links.WhatIsPersonCredential)} testID={testIdWithKey('WhatIsPersonCredentialLink')} /> !workflowInProgress && openLink(links.WhereToUse)} + onPress={() => openLink(links.WhereToUse)} testID={testIdWithKey('WhereToUse')} /> !workflowInProgress && openLink(links.Help)} + onPress={() => openLink(links.Help)} testID={testIdWithKey('Help')} /> diff --git a/app/src/screens/PersonCredentialLoading.tsx b/app/src/screens/PersonCredentialLoading.tsx new file mode 100644 index 000000000..7f5a074f4 --- /dev/null +++ b/app/src/screens/PersonCredentialLoading.tsx @@ -0,0 +1,263 @@ +import { CredentialState, ProofExchangeRecord, ProofState } from '@credo-ts/core' +import { useAgent, useCredentialByState, useProofByState } from '@credo-ts/react-hooks' +import { + BifoldAgent, + Button, + ButtonType, + Screens, + Stacks, + TOKENS, + testIdWithKey, + useContainer, + useStore, + useTheme, + EventTypes as BifoldEventTypes, + BifoldError, +} from '@hyperledger/aries-bifold-core' +import { useNavigation } from '@react-navigation/native' +import React, { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + DeviceEventEmitter, + EmitterSubscription, + Modal, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' + +import PersonCredentialSpinner from '../components/PersonCredentialSpinner' +import { connectToIASAgent, authenticateWithServiceCard, WellKnownAgentDetails } from '../helpers/BCIDHelper' +import { useAttestation } from '../hooks/useAttestation' +import { AttestationEventTypes } from '../services/attestation' +import { BCState } from '../store' + +export default function PersonCredentialLoading() { + const { ColorPallet, TextTheme } = useTheme() + const [store] = useStore() + const [remoteAgentDetails, setRemoteAgentDetails] = useState() + const receivedProofRequests = useProofByState(ProofState.RequestReceived) + const timer = useRef() + const logger = useContainer().resolve(TOKENS.UTIL_LOGGER) + const receivedCredentialOffers = useCredentialByState(CredentialState.OfferReceived) + const { loading: attestationLoading } = useAttestation ? useAttestation() : { loading: false } + const { agent } = useAgent() + if (!agent) { + throw new Error('Unable to fetch agent from Credo') + } + const navigation = useNavigation() + const { t } = useTranslation() + const attestationProofRequestWaitTimeout = 10000 + const [didCompleteAttestationProofRequest, setDidCompleteAttestationProofRequest] = useState(false) + + const styles = StyleSheet.create({ + container: { + height: '100%', + backgroundColor: ColorPallet.brand.modalPrimaryBackground, + padding: 20, + }, + image: { + marginTop: 80, + }, + messageContainer: { + alignItems: 'center', + marginTop: 40, + }, + messageText: { + fontWeight: TextTheme.normal.fontWeight, + textAlign: 'center', + marginTop: 30, + }, + controlsContainer: { + marginTop: 'auto', + margin: 20, + }, + delayMessageText: { + textAlign: 'center', + marginTop: 20, + }, + }) + + useEffect(() => { + connectToIASAgent(agent, store, t) + .then((remoteAgentDetails: WellKnownAgentDetails) => { + setRemoteAgentDetails(remoteAgentDetails) + + timer.current = setTimeout(() => { + if (!remoteAgentDetails || !remoteAgentDetails.connectionId) { + return + } + + const proofRequest = receivedProofRequests.find( + (proof) => proof.connectionId === remoteAgentDetails.connectionId + ) + + if (!proofRequest) { + // No proof from our IAS Agent to respond to, do nothing. + logger.info( + `Waited ${attestationProofRequestWaitTimeout / 1000}sec on attestation proof request, continuing` + ) + + setDidCompleteAttestationProofRequest(true) + } + }, attestationProofRequestWaitTimeout) + + logger.error(`Connected to IAS agent, connectionId: ${remoteAgentDetails?.connectionId}`) + }) + .catch((error) => { + logger.error(`Failed to connect to IAS agent, error: ${error.message}`) + }) + }, []) + + // Use this function to accept the attestation proof request. + const acceptAttestationProofRequest = async (agent: BifoldAgent, proofRequest: ProofExchangeRecord) => { + logger.info('Attestation: selecting credentials for attestation proof request') + // This will throw if we don't have the necessary credentials + const credentials = await agent.proofs.selectCredentialsForRequest({ + proofRecordId: proofRequest.id, + }) + + logger.info('Attestation: accepting attestation proof request') + await agent.proofs.acceptRequest({ + proofRecordId: proofRequest.id, + proofFormats: credentials.proofFormats, + }) + + return true + } + + // when a person credential offer is received, show the + // offer screen to the user. + const goToCredentialOffer = (credentialId?: string) => { + navigation.getParent()?.navigate(Stacks.NotificationStack, { + screen: Screens.CredentialOffer, + params: { credentialId }, + }) + } + + useEffect(() => { + const handleFailedAttestation = (error: BifoldError) => { + navigation.goBack() + DeviceEventEmitter.emit(BifoldEventTypes.ERROR_ADDED, error) + } + + const subscriptions = Array() + subscriptions.push(DeviceEventEmitter.addListener(AttestationEventTypes.FailedHandleProof, handleFailedAttestation)) + subscriptions.push(DeviceEventEmitter.addListener(AttestationEventTypes.FailedHandleOffer, handleFailedAttestation)) + subscriptions.push( + DeviceEventEmitter.addListener(AttestationEventTypes.FailedRequestCredential, handleFailedAttestation) + ) + + return () => { + subscriptions.forEach((subscription) => subscription.remove()) + } + }, []) + + useEffect(() => { + // If we are fetching an attestation credential, do no yet have + // a remote connection ID to the IAS agent, or the agent is not + // initialized, do nothing. + if (attestationLoading || !remoteAgentDetails || !agent) { + return + } + + // We have an attestation credential and can respond to an + // attestation proof request. + const proofRequest = receivedProofRequests.find((proof) => proof.connectionId === remoteAgentDetails.connectionId) + if (!proofRequest) { + // No proof from our IAS Agent to respond to, do nothing. + return + } + + timer.current && clearTimeout(timer.current) + + if (!didCompleteAttestationProofRequest) { + acceptAttestationProofRequest(agent, proofRequest) + .then((status: boolean) => { + // We can unblock the workflow and proceed with + // authentication. + setDidCompleteAttestationProofRequest(status) + logger.info(`Accepted IAS attestation proof request with status: ${status}`) + }) + .catch((error) => { + setDidCompleteAttestationProofRequest(false) + logger.error(`Unable to accept IAS attestation proof request, error: ${error.message}`) + }) + } + }, [attestationLoading, receivedProofRequests, remoteAgentDetails, agent]) + + useEffect(() => { + if (!remoteAgentDetails || !remoteAgentDetails.legacyConnectionDid || !didCompleteAttestationProofRequest) { + return + } + + const cb = (status: boolean) => { + logger.info(`Service card authentication reported ${status}`) + // TODO(jl): Handle the case where the service card authentication fails for + // user reasons or otherwise. + if (!status) { + setDidCompleteAttestationProofRequest(false) + } + } + + const { iasPortalUrl } = store.developer.environment + const { legacyConnectionDid } = remoteAgentDetails + + authenticateWithServiceCard(legacyConnectionDid, iasPortalUrl, cb) + .then(() => { + logger.error('Completed service card authentication successfully') + }) + .catch((error) => { + logger.error('Completed service card authentication with error, error: ', error.message) + }) + }, [remoteAgentDetails, didCompleteAttestationProofRequest]) + + useEffect(() => { + if (!remoteAgentDetails || !remoteAgentDetails.connectionId) { + return + } + + for (const credential of receivedCredentialOffers) { + if ( + credential.state == CredentialState.OfferReceived && + credential.connectionId === remoteAgentDetails.connectionId + ) { + goToCredentialOffer(credential.id) + } + } + }, [receivedCredentialOffers, remoteAgentDetails]) + + const onDismissModalTouched = () => { + navigation.goBack() + } + + return ( + + + + + + {t('ProofRequest.RequestProcessing')} + + + + + + + This can take a few seconds + + +