From bb89612d6d2b154197d5897d5c73b134213e454d Mon Sep 17 00:00:00 2001 From: balachandarg-tw Date: Wed, 23 Apr 2025 15:43:28 +0530 Subject: [PATCH] [INJIMOB-3154]: Same device flow inOVP for Android (#1884) * [INJIMOB-3154]: Handle deeplink intent data in Android Signed-off-by: BalachandarG * [INJIMOB-3154]: Handling same device flow and sending response back to Verifier Signed-off-by: BalachandarG Co-authored-by: sairam-girirao_infosys * [INJIMOB-3154]: Review comments addressed Signed-off-by: BalachandarG Co-authored-by: sairam-girirao_infosys * [INJIMOB-3154]: Share Success screen buttons are rendered based on buttonStatus props Signed-off-by: BalachandarG Co-authored-by: sairam-girirao_infosys * [INJIMOB-3154]: Update buttonStatus type Signed-off-by: BalachandarG Co-authored-by: sairam-girirao_infosys * [INJIMOB-3154]: Hanle Network Error Retry Scenario Signed-off-by: BalachandarG Co-authored-by: sairam-girirao_infosys --------- Signed-off-by: BalachandarG Co-authored-by: sairam-girirao_infosys --- android/app/src/main/AndroidManifest.xml | 6 +- .../io/mosip/residentapp/InjiPackage.java | 2 +- .../java/io/mosip/residentapp/IntentData.java | 28 +++++- .../io/mosip/residentapp/MainActivity.java | 25 +++-- ...odule.java => RNDeepLinkIntentModule.java} | 21 +++-- components/ui/Error.tsx | 94 +++++++++++-------- machines/app.ts | 56 +++++++++-- machines/bleShare/scan/scanActions.ts | 9 ++ machines/bleShare/scan/scanGuards.ts | 1 + machines/bleShare/scan/scanMachine.ts | 22 ++++- machines/bleShare/scan/scanModel.ts | 3 + machines/bleShare/scan/scanSelectors.ts | 4 + machines/openID4VP/openID4VPActions.ts | 38 +++++--- machines/openID4VP/openID4VPMachine.ts | 1 + machines/openID4VP/openID4VPModel.ts | 4 +- machines/openID4VP/openID4VPSelectors.ts | 36 ++++--- screens/MainLayout.tsx | 11 ++- screens/Scan/ScanLayout.tsx | 4 +- screens/Scan/ScanLayoutController.ts | 26 ++++- screens/Scan/ScanScreen.tsx | 47 ++++++++-- screens/Scan/ScanScreenController.ts | 6 +- screens/Scan/SendVPScreen.tsx | 74 +++++++++++++-- screens/Scan/SendVPScreenController.ts | 2 + screens/Scan/SharingStatusModal.tsx | 24 ++++- shared/Utils.ts | 5 + shared/constants.ts | 5 + 26 files changed, 430 insertions(+), 124 deletions(-) rename android/app/src/main/java/io/mosip/residentapp/{RNQrLoginIntentModule.java => RNDeepLinkIntentModule.java} (51%) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4b70dcb9..e477c807 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -75,6 +75,10 @@ + + @@ -84,4 +88,4 @@ - \ No newline at end of file + diff --git a/android/app/src/main/java/io/mosip/residentapp/InjiPackage.java b/android/app/src/main/java/io/mosip/residentapp/InjiPackage.java index 02a57de5..5eb88d21 100644 --- a/android/app/src/main/java/io/mosip/residentapp/InjiPackage.java +++ b/android/app/src/main/java/io/mosip/residentapp/InjiPackage.java @@ -25,7 +25,7 @@ public class InjiPackage implements ReactPackage { modules.add(new RNVersionModule()); modules.add(new RNWalletModule(new RNEventEmitter(reactApplicationContext), new Wallet(reactApplicationContext), reactApplicationContext)); modules.add(new RNVerifierModule(new RNEventEmitter(reactApplicationContext), new Verifier(reactApplicationContext), reactApplicationContext)); - modules.add(new RNQrLoginIntentModule(reactApplicationContext)); + modules.add(new RNDeepLinkIntentModule(reactApplicationContext)); modules.add(new InjiOpenID4VPModule(reactApplicationContext)); modules.add(new RNVCVerifierModule(reactApplicationContext)); return modules; diff --git a/android/app/src/main/java/io/mosip/residentapp/IntentData.java b/android/app/src/main/java/io/mosip/residentapp/IntentData.java index b0684c50..6d9a167d 100644 --- a/android/app/src/main/java/io/mosip/residentapp/IntentData.java +++ b/android/app/src/main/java/io/mosip/residentapp/IntentData.java @@ -2,6 +2,8 @@ package io.mosip.residentapp; public class IntentData { private String qrData = ""; + private String ovpQrData = ""; + private static IntentData intentData; public static IntentData getInstance() { if(intentData == null) @@ -16,4 +18,28 @@ public class IntentData { this.qrData = qrData; } -} \ No newline at end of file + public String getOVPQrData() { + return ovpQrData; + } + + public void setOVPQrData(String ovpQrData) { + this.ovpQrData = ovpQrData; + } + + public String getDataByFlow(String flowType) { + if (flowType == null) return ""; + return switch (flowType) { + case "qrLoginFlow" -> getQrData(); + case "ovpFlow" -> getOVPQrData(); + default -> ""; + }; + } + + public void resetDataByFlow(String flowType) { + if (flowType == null) return; + switch (flowType) { + case "qrLoginFlow" -> setQrData(""); + case "ovpFlow" -> setOVPQrData(""); + } + } +} diff --git a/android/app/src/main/java/io/mosip/residentapp/MainActivity.java b/android/app/src/main/java/io/mosip/residentapp/MainActivity.java index b69e716d..230caf30 100644 --- a/android/app/src/main/java/io/mosip/residentapp/MainActivity.java +++ b/android/app/src/main/java/io/mosip/residentapp/MainActivity.java @@ -43,20 +43,33 @@ public class MainActivity extends ReactActivity { setTheme(R.style.AppTheme); super.onCreate(null); Intent intent = getIntent(); - readAndSetQRLoginIntentData(intent); + handleIntent(intent); } @Override public void onNewIntent(Intent intent) { super.onNewIntent(intent); - readAndSetQRLoginIntentData(intent); + handleIntent(intent); } - private void readAndSetQRLoginIntentData(Intent intent){ + private void handleIntent(Intent intent) { + if (intent == null || intent.getData() == null) return; + Uri data = intent.getData(); - if(data != null && Objects.equals(data.getScheme(), "io.mosip.residentapp.inji")){ - IntentData intentData = IntentData.getInstance(); - intentData.setQrData(String.valueOf(data)); + String scheme = data.getScheme(); + IntentData intentData = IntentData.getInstance(); + + if (scheme == null) return; + + switch (scheme) { + case "io.mosip.residentapp.inji": + intentData.setQrData(data.toString()); + break; + case "openid4vp": + intentData.setOVPQrData(data.toString()); + break; + default: + break; } } diff --git a/android/app/src/main/java/io/mosip/residentapp/RNQrLoginIntentModule.java b/android/app/src/main/java/io/mosip/residentapp/RNDeepLinkIntentModule.java similarity index 51% rename from android/app/src/main/java/io/mosip/residentapp/RNQrLoginIntentModule.java rename to android/app/src/main/java/io/mosip/residentapp/RNDeepLinkIntentModule.java index f471f55f..14ed5080 100644 --- a/android/app/src/main/java/io/mosip/residentapp/RNQrLoginIntentModule.java +++ b/android/app/src/main/java/io/mosip/residentapp/RNDeepLinkIntentModule.java @@ -5,24 +5,24 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; -public class RNQrLoginIntentModule extends ReactContextBaseJavaModule { +public class RNDeepLinkIntentModule extends ReactContextBaseJavaModule { @Override public String getName() { - return "QrLoginIntent"; + return "DeepLinkIntent"; } - RNQrLoginIntentModule(ReactApplicationContext context) { + RNDeepLinkIntentModule(ReactApplicationContext context) { super(context); } @ReactMethod - public void isQrLoginByDeepLink(Promise promise) { + public void getDeepLinkIntentData(String flowType, Promise promise) { try { - IntentData intentData = IntentData.getInstance(); - promise.resolve(intentData.getQrData()); + String result = intentData.getDataByFlow(flowType); + promise.resolve(result); } catch (Exception e) { promise.reject("E_UNKNOWN", e.getMessage()); @@ -30,9 +30,10 @@ public class RNQrLoginIntentModule extends ReactContextBaseJavaModule { } @ReactMethod - public void resetQRLoginDeepLinkData(){ - IntentData intentData = IntentData.getInstance(); - intentData.setQrData(""); + public void resetDeepLinkIntentData(String flowType) { + IntentData intentData = IntentData.getInstance(); + intentData.resetDataByFlow(flowType); } -} \ No newline at end of file +} + diff --git a/components/ui/Error.tsx b/components/ui/Error.tsx index cc2f093a..8a76628f 100644 --- a/components/ui/Error.tsx +++ b/components/ui/Error.tsx @@ -1,5 +1,5 @@ import {useFocusEffect} from '@react-navigation/native'; -import React, {Fragment} from 'react'; +import React, {Fragment, useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {BackHandler, Dimensions, View} from 'react-native'; import {ButtonProps as RNEButtonProps} from 'react-native-elements'; @@ -11,54 +11,70 @@ import {Modal} from './Modal'; export const Error: React.FC = props => { const {t} = useTranslation('common'); - const { - testID, - customStyles = {}, - customImageStyles = {}, - goBackType, - isModal = false, - isVisible, - showClose = true, - alignActionsOnEnd = false, - title, - message, - helpText, - image, - goBack, - goBackButtonVisible = false, - tryAgain, - onDismiss, - primaryButtonText, - primaryButtonEvent, - testIDTextButton, - textButtonText, - textButtonEvent, - primaryButtonTestID, - textButtonTestID, - } = props; + const { + testID, + customStyles = {}, + customImageStyles = {}, + goBackType, + isModal = false, + isVisible, + showClose = true, + alignActionsOnEnd = false, + title, + message, + helpText, + image, + goBack, + goBackButtonVisible = false, + tryAgain, + onDismiss, + primaryButtonText, + primaryButtonEvent, + testIDTextButton, + textButtonText, + textButtonEvent, + primaryButtonTestID, + textButtonTestID, + } = props; + + const [triggerExitFlow, setTriggerExitFlow] = useState(false); + + useEffect(() => { + if ( + props.isVisible && + props.textButtonText === undefined && + props.primaryButtonText === undefined + ) { + const timeout = setTimeout(() => { + setTriggerExitFlow(true); + }, 2000); + + return () => clearTimeout(timeout); + } + }, [props.isVisible, props.textButtonText, props.primaryButtonText]); + + useEffect(() => { + if (triggerExitFlow) { + props.textButtonEvent(); + BackHandler.exitApp(); + } + }, [triggerExitFlow]); const errorContent = () => { return ( + style={[{alignItems: 'center', marginHorizontal: 1}, customStyles]}> {image} - + {title} - + {message} @@ -69,9 +85,7 @@ export const Error: React.FC = props => { onPress={primaryButtonEvent} title={t(primaryButtonText)} type={ - primaryButtonText === 'tryAgain' - ? 'outline' - : 'gradient' + primaryButtonText === 'tryAgain' ? 'outline' : 'gradient' } width={ primaryButtonText === 'tryAgain' @@ -192,4 +206,4 @@ export interface ErrorProps { textButtonEvent?: () => void; primaryButtonTestID?: string; textButtonTestID?: string; -} \ No newline at end of file +} diff --git a/machines/app.ts b/machines/app.ts index 3889e88d..84ae474a 100644 --- a/machines/app.ts +++ b/machines/app.ts @@ -14,12 +14,12 @@ import { import {createScanMachine, scanMachine} from './bleShare/scan/scanMachine'; import {pure, respond} from 'xstate/lib/actions'; import {AppServices} from '../shared/GlobalContext'; +import {DEEPLINK_FLOWS} from '../shared/Utils'; import { changeCrendetialRegistry, changeEsignetUrl, ESIGNET_BASE_URL, isAndroid, - isIOS, MIMOTO_BASE_URL, SETTINGS_STORE_KEY, } from '../shared/constants'; @@ -41,7 +41,7 @@ import { generateKeyPairsAndStoreOrder, } from '../shared/cryptoutil/cryptoUtil'; -const QrLoginIntent = NativeModules.QrLoginIntent; +const DeepLinkIntent = NativeModules.DeepLinkIntent; const model = createModel( { @@ -51,6 +51,7 @@ const model = createModel( isDecryptError: false, isKeyInvalidateError: false, linkCode: '', + authorizationRequest: '', }, { events: { @@ -68,6 +69,7 @@ const model = createModel( STORE_RESPONSE: (response: unknown) => ({response}), RESET_KEY_INVALIDATE_ERROR_DISMISS: () => ({}), RESET_LINKCODE: () => ({}), + RESET_AUTHORIZATION_REQUEST: () => ({}), BIOMETRIC_CANCELLED: () => ({}), }, }, @@ -94,6 +96,9 @@ export const appMachine = model.createMachine( RESET_LINKCODE: { actions: ['resetLinkCode'], }, + RESET_AUTHORIZATION_REQUEST: { + actions: ['resetAuthorizationRequest'], + }, DECRYPT_ERROR_DISMISS: { actions: ['unsetIsDecryptError'], }, @@ -215,13 +220,22 @@ export const appMachine = model.createMachine( entry: ['forwardToServices'], invoke: [ { - src: 'isQrLoginByDeepLink', + src: 'getQrLoginDeepLinkIntent', onDone: { actions: ['setLinkCode'], }, }, { - src: 'resetQRLoginDeepLinkData', + src: 'resetQrLoginDeepLinkIntent', + }, + { + src: 'getOVPDeepLinkIntent', + onDone: { + actions: ['setAuthorizationRequest'], + }, + }, + { + src: 'resetOVPDeepLinkIntent', }, ], }, @@ -266,7 +280,13 @@ export const appMachine = model.createMachine( resetLinkCode: assign({ linkCode: '', }), - forwardToServices: pure((context, event) => + setAuthorizationRequest: assign({ + authorizationRequest: (_, event) => event.data || '', + }), + resetAuthorizationRequest: assign({ + authorizationRequest: '', + }), + forwardToSerices: pure((context, event) => Object.values(context.serviceRefs).map(serviceRef => send({...event, type: `APP_${event.type}`}, {to: serviceRef}), ), @@ -413,14 +433,26 @@ export const appMachine = model.createMachine( }, services: { - isQrLoginByDeepLink: () => async () => { - const data = await QrLoginIntent.isQrLoginByDeepLink(); + getQrLoginDeepLinkIntent: () => async () => { + const data = await DeepLinkIntent.getDeepLinkIntentData( + DEEPLINK_FLOWS.QR_LOGIN, + ); return data; }, - resetQRLoginDeepLinkData: () => async () => { - return await QrLoginIntent.resetQRLoginDeepLinkData(); + resetQrLoginDeepLinkIntent: () => async () => { + return await DeepLinkIntent.resetDeepLinkIntentData( + DEEPLINK_FLOWS.QR_LOGIN, + ); + }, + getOVPDeepLinkIntent: () => async () => { + const data = await DeepLinkIntent.getDeepLinkIntentData( + DEEPLINK_FLOWS.OVP, + ); + return data; + }, + resetOVPDeepLinkIntent: () => async () => { + return await DeepLinkIntent.resetDeepLinkIntentData(DEEPLINK_FLOWS.OVP); }, - getAppInfo: () => async callback => { const appInfo = { deviceId: getDeviceId(), @@ -534,3 +566,7 @@ export function selectIsKeyInvalidateError(state: State) { export function selectIsLinkCode(state: State) { return state.context.linkCode; } + +export function selectAuthorizationRequest(state: State) { + return state.context.authorizationRequest; +} diff --git a/machines/bleShare/scan/scanActions.ts b/machines/bleShare/scan/scanActions.ts index c744fdbc..e63fd576 100644 --- a/machines/bleShare/scan/scanActions.ts +++ b/machines/bleShare/scan/scanActions.ts @@ -107,6 +107,7 @@ export const ScanActions = (model: any) => { encodedAuthRequest: context.linkCode, flowType: context.openID4VPFlowType, selectedVC: context.selectedVc, + isOVPViaDeepLink: context.isOVPViaDeepLink, }), openBluetoothSettings: () => { @@ -292,6 +293,14 @@ export const ScanActions = (model: any) => { isQrLoginViaDeepLink: false, }), + setIsOVPViaDeepLink: assign({ + isOVPViaDeepLink: true, + }), + + resetIsOVPViaDeepLink: assign({ + isOVPViaDeepLink: false, + }), + setQuickShareData: assign({ quickShareData: (_, event) => JSON.parse( diff --git a/machines/bleShare/scan/scanGuards.ts b/machines/bleShare/scan/scanGuards.ts index f009806d..fc3fa1a4 100644 --- a/machines/bleShare/scan/scanGuards.ts +++ b/machines/bleShare/scan/scanGuards.ts @@ -36,6 +36,7 @@ export const ScanGuards = () => { Boolean(event.data), isQrLoginViaDeepLinking: context => context.isQrLoginViaDeepLink === true, + isOVPViaDeepLink: context => context.isOVPViaDeepLink === true, isFlowTypeMiniViewShareWithSelfie: context => context.flowType === VCShareFlowType.MINI_VIEW_SHARE_WITH_SELFIE, diff --git a/machines/bleShare/scan/scanMachine.ts b/machines/bleShare/scan/scanMachine.ts index 78710276..17778b9b 100644 --- a/machines/bleShare/scan/scanMachine.ts +++ b/machines/bleShare/scan/scanMachine.ts @@ -73,6 +73,14 @@ export const scanMachine = ], target: '#scan.checkStorage', }, + OVP_VIA_DEEP_LINK: { + actions: [ + 'setLinkCodeFromDeepLink', + 'setIsOVPViaDeepLink', + 'setOpenId4VPFlowType', + ], + target: '#scan.checkStorage', + }, }, states: { inactive: { @@ -102,6 +110,10 @@ export const scanMachine = cond: 'isMinimumStorageRequiredForAuditEntryReached', target: 'restrictSharingVc', }, + { + cond: 'isOVPViaDeepLink', + target: '#scan.checkFaceAuthConsent', + }, { target: 'startPermissionCheck', }, @@ -316,16 +328,20 @@ export const scanMachine = on: { STORE_RESPONSE: { actions: 'updateShowFaceAuthConsent', - target: '#scan.checkQrLoginViaDeepLink', + target: '#scan.checkForDeepLinkFlow', }, }, }, - checkQrLoginViaDeepLink: { + checkForDeepLinkFlow: { always: [ { cond: 'isQrLoginViaDeepLinking', target: '#scan.showQrLogin', }, + { + cond: 'isOVPViaDeepLink', + target: '#scan.startVPSharing', + }, { target: '#scan.findingConnection', }, @@ -393,7 +409,7 @@ export const scanMachine = DISMISS: [ { cond: 'isFlowTypeSimpleShare', - actions: 'resetOpenID4VPFlowType', + actions: ['resetIsOVPViaDeepLink', 'resetOpenID4VPFlowType'], target: 'checkStorage', }, { diff --git a/machines/bleShare/scan/scanModel.ts b/machines/bleShare/scan/scanModel.ts index 55083eaf..c4c7c500 100644 --- a/machines/bleShare/scan/scanModel.ts +++ b/machines/bleShare/scan/scanModel.ts @@ -61,6 +61,7 @@ const ScanEvents = { IN_PROGRESS: () => ({}), TIMEOUT: () => ({}), QRLOGIN_VIA_DEEP_LINK: (linkCode: string) => ({linkCode}), + OVP_VIA_DEEP_LINK: (linkCode: string) => ({linkCode}), }; export const ScanModel = createModel( @@ -81,8 +82,10 @@ export const ScanModel = createModel( OpenId4VPRef: {} as ActorRefFrom, showQuickShareSuccessBanner: false, linkCode: '', + authorizationRequest: '', quickShareData: {}, isQrLoginViaDeepLink: false, + isOVPViaDeepLink: false, showFaceAuthConsent: true as boolean, readyForBluetoothStateCheck: false, showFaceCaptureSuccessBanner: false, diff --git a/machines/bleShare/scan/scanSelectors.ts b/machines/bleShare/scan/scanSelectors.ts index 6bef55e0..ea62250a 100644 --- a/machines/bleShare/scan/scanSelectors.ts +++ b/machines/bleShare/scan/scanSelectors.ts @@ -149,3 +149,7 @@ export function selectIsMinimumStorageRequiredForAuditEntryLimitReached( export function selectIsFaceVerificationConsent(state: State) { return state.matches('reviewing.faceVerificationConsent'); } + +export function selectIsOVPViaDeepLink(state: State) { + return state.context.isOVPViaDeepLink; +} diff --git a/machines/openID4VP/openID4VPActions.ts b/machines/openID4VP/openID4VPActions.ts index 98fd8f5d..4bd0e52c 100644 --- a/machines/openID4VP/openID4VPActions.ts +++ b/machines/openID4VP/openID4VPActions.ts @@ -1,6 +1,9 @@ import {assign} from 'xstate'; import {send, sendParent} from 'xstate/lib/actions'; -import {SHOW_FACE_AUTH_CONSENT_SHARE_FLOW} from '../../shared/constants'; +import { + OVP_ERROR_MESSAGES, + SHOW_FACE_AUTH_CONSENT_SHARE_FLOW, +} from '../../shared/constants'; import {VC} from '../VerifiableCredential/VCMetaMachine/vc'; import {StoreEvents} from '../store'; import {JSONPath} from 'jsonpath-plus'; @@ -77,6 +80,9 @@ export const openID4VPActions = (model: any) => { if (!anyInputDescriptorHasFormatOrConstraints) { matchingVCs[presentationDefinition['input_descriptors'][0].id] = vcs; } + if (Object.keys(matchingVCs).length === 0) { + OpenID4VP.sendErrorToVerifier(OVP_ERROR_MESSAGES.NO_MATCHING_VCS); + } return matchingVCs; }, requestedClaims: () => Array.from(requestedClaimsByVerifier).join(','), @@ -95,15 +101,15 @@ export const openID4VPActions = (model: any) => { selectedVCs: context => { const matchingVcs = {}; Object.entries(context.vcsMatchingAuthRequest).map( - ([inputDescriptorId, vcs]) => - (vcs as VC[]).map(vcData => { - if ( - vcData.vcMetadata.requestId === - context.miniViewSelectedVC.vcMetadata.requestId - ) { - matchingVcs[inputDescriptorId] = [vcData]; - } - }), + ([inputDescriptorId, vcs]) => + (vcs as VC[]).map(vcData => { + if ( + vcData.vcMetadata.requestId === + context.miniViewSelectedVC.vcMetadata.requestId + ) { + matchingVcs[inputDescriptorId] = [vcData]; + } + }), ); return matchingVcs; }, @@ -119,6 +125,14 @@ export const openID4VPActions = (model: any) => { VCShareFlowType.MINI_VIEW_SHARE_WITH_SELFIE_OPENID4VP, }), + setIsOVPViaDeepLink: model.assign({ + isOVPViaDeepLink: (_, event) => event.isOVPViaDeepLink, + }), + + resetIsOVPViaDeepLink: model.assign({ + isOVPViaDeepLink: () => false, + }), + setShowFaceAuthConsent: model.assign({ showFaceAuthConsent: (_, event) => { return !event.isDoNotAskAgainChecked; @@ -246,9 +260,7 @@ export const openID4VPActions = (model: any) => { ), shareDeclineStatus: () => { - OpenID4VP.sendErrorToVerifier( - 'The user has declined to share their credentials at this time', - ); + OpenID4VP.sendErrorToVerifier(OVP_ERROR_MESSAGES.DECLINED); }, setIsFaceVerificationRetryAttempt: model.assign({ diff --git a/machines/openID4VP/openID4VPMachine.ts b/machines/openID4VP/openID4VPMachine.ts index ab956c66..c4b1caf9 100644 --- a/machines/openID4VP/openID4VPMachine.ts +++ b/machines/openID4VP/openID4VPMachine.ts @@ -46,6 +46,7 @@ export const openID4VPMachine = model.createMachine( 'setFlowType', 'setMiniViewShareSelectedVC', 'setIsShareWithSelfie', + 'setIsOVPViaDeepLink', ], target: 'checkFaceAuthConsent', }, diff --git a/machines/openID4VP/openID4VPModel.ts b/machines/openID4VP/openID4VPModel.ts index 66306fff..98a7a25a 100644 --- a/machines/openID4VP/openID4VPModel.ts +++ b/machines/openID4VP/openID4VPModel.ts @@ -9,7 +9,8 @@ const openID4VPEvents = { encodedAuthRequest: string, flowType: string, selectedVC: any, - ) => ({encodedAuthRequest, flowType, selectedVC}), + isOVPViaDeepLink: boolean, + ) => ({encodedAuthRequest, flowType, selectedVC, isOVPViaDeepLink}), DOWNLOADED_VCS: (vcs: VC[]) => ({vcs}), SELECT_VC: (vcKey: string, inputDescriptorId: any) => ({ vcKey, @@ -66,6 +67,7 @@ export const openID4VPModel = createModel( isFaceVerificationRetryAttempt: false as boolean, requestedClaims: '' as string, showLoadingScreen: false as boolean, + isOVPViaDeepLink: false, }, {events: openID4VPEvents}, ); diff --git a/machines/openID4VP/openID4VPSelectors.ts b/machines/openID4VP/openID4VPSelectors.ts index 383d5faa..87734ca9 100644 --- a/machines/openID4VP/openID4VPSelectors.ts +++ b/machines/openID4VP/openID4VPSelectors.ts @@ -2,7 +2,10 @@ import {StateFrom} from 'xstate'; import {openID4VPMachine} from './openID4VPMachine'; import {VCMetadata} from '../../shared/VCMetadata'; import {getMosipLogo} from '../../components/VC/common/VCUtils'; -import {Credential, VerifiableCredentialData} from '../VerifiableCredential/VCMetaMachine/vc'; +import { + Credential, + VerifiableCredentialData, +} from '../VerifiableCredential/VCMetaMachine/vc'; type State = StateFrom; @@ -48,20 +51,23 @@ export function selectIsShowLoadingScreen(state: State) { export function selectCredentials(state: State) { const processCredential = (vcData: any) => - vcData?.verifiableCredential?.credential || - vcData?.verifiableCredential - let selectedCredentials: Credential[] = Object.values(state.context.selectedVCs) - .flatMap(innerMap => Object.values(innerMap)) // Extract arrays - .flat() - .map(processCredential); + vcData?.verifiableCredential?.credential || vcData?.verifiableCredential; + let selectedCredentials: Credential[] = Object.values( + state.context.selectedVCs, + ) + .flatMap(innerMap => Object.values(innerMap)) // Extract arrays + .flat() + .map(processCredential); return selectCredentials.length === 0 ? undefined : selectedCredentials; } export function selectVerifiableCredentialsData(state: State) { let verifiableCredentialsData: VerifiableCredentialData[] = []; - let selectedCredentials: Credential[] = Object.values(state.context.selectedVCs) - .flatMap(innerMap => Object.values(innerMap)) - .flat(); + let selectedCredentials: Credential[] = Object.values( + state.context.selectedVCs, + ) + .flatMap(innerMap => Object.values(innerMap)) + .flat(); selectedCredentials.map(vcData => { const vcMetadata = new VCMetadata(vcData.vcMetadata); verifiableCredentialsData.push({ @@ -69,12 +75,12 @@ export function selectVerifiableCredentialsData(state: State) { issuer: vcMetadata.issuer, issuerLogo: vcData?.verifiableCredential?.issuerLogo || getMosipLogo(), face: - vcData?.verifiableCredential?.credential?.credentialSubject?.face || - vcData?.credential?.biometrics?.face, + vcData?.verifiableCredential?.credential?.credentialSubject?.face || + vcData?.credential?.biometrics?.face, wellKnown: vcData?.verifiableCredential?.wellKnown, credentialTypes: vcData?.verifiableCredential?.credentialTypes, }); - return verifiableCredentialsData + return verifiableCredentialsData; }); return verifiableCredentialsData; @@ -100,6 +106,10 @@ export function selectOpenID4VPRetryCount(state: State) { return state.context.openID4VPRetryCount; } +export function selectIsOVPViaDeeplink(state: State) { + return state.context.isOVPViaDeepLink; +} + export function selectIsFaceVerifiedInVPSharing(state: State) { return ( state.matches('sendingVP') && state.context.showFaceCaptureSuccessBanner diff --git a/screens/MainLayout.tsx b/screens/MainLayout.tsx index 91fc22bd..ab33bc60 100644 --- a/screens/MainLayout.tsx +++ b/screens/MainLayout.tsx @@ -20,7 +20,7 @@ import {Copilot} from '../components/ui/Copilot'; import LinearGradient from 'react-native-linear-gradient'; import {useNavigation} from '@react-navigation/native'; import {useSelector} from '@xstate/react'; -import {selectIsLinkCode} from '../machines/app'; +import {selectAuthorizationRequest, selectIsLinkCode} from '../machines/app'; import {BOTTOM_TAB_ROUTES} from '../routes/routesConstants'; const {Navigator, Screen} = createBottomTabNavigator(); @@ -40,11 +40,16 @@ export const MainLayout: React.FC = () => { const linkCode = useSelector(appService, selectIsLinkCode); + const authorizationRequest = useSelector( + appService, + selectAuthorizationRequest, + ); + useEffect(() => { - if (linkCode != '') { + if (linkCode !== '' || authorizationRequest !== '') { navigation.navigate(BOTTOM_TAB_ROUTES.share); } - }, [linkCode]); + }, [linkCode, authorizationRequest]); return ( { { if (linkCode != '') { scanService.send(ScanEvents.QRLOGIN_VIA_DEEP_LINK(linkCode)); appService.send(APP_EVENTS.RESET_LINKCODE()); + } else if (authorizationRequest != '' && shareableVcsMetadata.length) { + scanService.send(ScanEvents.OVP_VIA_DEEP_LINK(authorizationRequest)); + appService.send(APP_EVENTS.RESET_AUTHORIZATION_REQUEST()); } else if (isQrLoginDoneViaDeeplink) { changeTabBarVisible('flex'); navigation.navigate(BOTTOM_TAB_ROUTES.home); @@ -322,6 +344,7 @@ export function useScanLayout() { isAccepted, linkCode, isQrLoginDoneViaDeeplink, + authorizationRequest, ]); return { @@ -361,5 +384,6 @@ export function useScanLayout() { isFaceVerifiedInVPSharing, CLOSE_BANNER, VP_SHARE_CLOSE_BANNER, + isOVPViaDeepLink, }; } diff --git a/screens/Scan/ScanScreen.tsx b/screens/Scan/ScanScreen.tsx index dcd77ca0..302174ca 100644 --- a/screens/Scan/ScanScreen.tsx +++ b/screens/Scan/ScanScreen.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useContext, useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import { ErrorMessageOverlay, @@ -10,8 +10,12 @@ import {Theme} from '../../components/ui/styleUtils'; import {QrLogin} from '../QrLogin/QrLogin'; import {useScanScreen} from './ScanScreenController'; import BluetoothStateManager from 'react-native-bluetooth-state-manager'; -import {Linking} from 'react-native'; -import {isIOS, LIVENESS_CHECK} from '../../shared/constants'; +import {BackHandler, Linking} from 'react-native'; +import { + isIOS, + LIVENESS_CHECK, + OVP_ERROR_MESSAGES, +} from '../../shared/constants'; import {BannerNotificationContainer} from '../../components/BannerNotificationContainer'; import {SharingStatusModal} from './SharingStatusModal'; import {SvgImage} from '../../components/ui/svg'; @@ -23,6 +27,9 @@ import {Error} from '../../components/ui/Error'; import {VPShareOverlay} from './VPShareOverlay'; import {VerifyIdentityOverlay} from '../VerifyIdentityOverlay'; import {VCShareFlowType} from '../../shared/Utils'; +import {APP_EVENTS} from '../../machines/app'; +import {GlobalContext} from '../../shared/GlobalContext'; +import {OpenID4VP} from '../../shared/openID4VP/OpenID4VP'; export const ScanScreen: React.FC = () => { const {t} = useTranslation('ScanScreen'); @@ -38,6 +45,8 @@ export const ScanScreen: React.FC = () => { sendVPScreenController.flowType === VCShareFlowType.MINI_VIEW_SHARE_WITH_SELFIE_OPENID4VP)); + const {appService} = useContext(GlobalContext); + useEffect(() => { (async () => { await BluetoothStateManager.onStateChange(state => { @@ -54,7 +63,7 @@ export const ScanScreen: React.FC = () => { useEffect(() => { if ( scanScreenController.isStartPermissionCheck && - !scanScreenController.isEmpty + !scanScreenController.isNoSharableVCs ) scanScreenController.START_PERMISSION_CHECK(); }); @@ -63,6 +72,24 @@ export const ScanScreen: React.FC = () => { if (scanScreenController.isQuickShareDone) scanScreenController.GOTO_HOME(); }, [scanScreenController.isQuickShareDone]); + useEffect(() => { + if ( + scanScreenController.isNoSharableVCs && + scanScreenController.authorizationRequest != '' + ) { + setTimeout(() => { + OpenID4VP.initialize(); + OpenID4VP.sendErrorToVerifier(OVP_ERROR_MESSAGES.NO_MATCHING_VCS); + BackHandler.exitApp(); + scanScreenController.GOTO_HOME(); + appService.send(APP_EVENTS.RESET_AUTHORIZATION_REQUEST()); + }, 2000); + } + }, [ + scanScreenController.isNoSharableVCs, + scanScreenController.authorizationRequest, + ]); + const openSettings = () => { Linking.openSettings(); }; @@ -175,7 +202,7 @@ export const ScanScreen: React.FC = () => { } function loadQRScanner() { - if (scanScreenController.isEmpty) { + if (scanScreenController.isNoSharableVCs) { return noShareableVcText(); } if (scanScreenController.selectIsInvalid) { @@ -215,7 +242,7 @@ export const ScanScreen: React.FC = () => { function displayStorageLimitReachedError(): React.ReactNode { return ( - !scanScreenController.isEmpty && ( + !scanScreenController.isNoSharableVCs && ( { function displayInvalidQRpopup(): React.ReactNode { return ( - !scanScreenController.isEmpty && ( + !scanScreenController.isNoSharableVCs && ( { } primaryButtonEvent={sendVPScreenController.RETRY} textButtonTestID={'home'} - textButtonText={t('ScanScreen:status.accepted.home')} + textButtonText={ + sendVPScreenController.isOVPViaDeepLink + ? undefined + : t('ScanScreen:status.accepted.home') + } textButtonEvent={handleTextButtonEvent} customImageStyles={{paddingBottom: 0, marginBottom: -6}} customStyles={{marginTop: '30%'}} diff --git a/screens/Scan/ScanScreenController.ts b/screens/Scan/ScanScreenController.ts index 4c1ce208..8000f66c 100644 --- a/screens/Scan/ScanScreenController.ts +++ b/screens/Scan/ScanScreenController.ts @@ -27,6 +27,7 @@ import {selectIsMinimumStorageRequiredForAuditEntryLimitReached} from '../../mac import {BOTTOM_TAB_ROUTES} from '../../routes/routesConstants'; import {MainBottomTabParamList} from '../../routes/routeTypes'; import {useNavigation, NavigationProp} from '@react-navigation/native'; +import {selectAuthorizationRequest} from '../../machines/app'; export function useScanScreen() { const {t} = useTranslation('ScanScreen'); @@ -80,9 +81,11 @@ export function useScanScreen() { scanService, selectIsLocationPermissionRationale, ); + const isNoSharableVCs = !shareableVcsMetadata.length; + return { locationError, - isEmpty: !shareableVcsMetadata.length, + isNoSharableVCs, isBluetoothPermissionDenied, isNearByDevicesPermissionDenied, isLocationDisabled, @@ -113,5 +116,6 @@ export function useScanScreen() { ALLOWED, DENIED, isLocalPermissionRational, + authorizationRequest: useSelector(appService, selectAuthorizationRequest), }; } diff --git a/screens/Scan/SendVPScreen.tsx b/screens/Scan/SendVPScreen.tsx index cdfb61c1..78a7836e 100644 --- a/screens/Scan/SendVPScreen.tsx +++ b/screens/Scan/SendVPScreen.tsx @@ -1,11 +1,11 @@ import {useFocusEffect} from '@react-navigation/native'; -import React, {useEffect, useLayoutEffect} from 'react'; +import React, {useEffect, useLayoutEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {BackHandler, I18nManager, View} from 'react-native'; import {Button, Column, Row, Text} from '../../components/ui'; import {Theme} from '../../components/ui/styleUtils'; import {VcItemContainer} from '../../components/VC/VcItemContainer'; -import {LIVENESS_CHECK} from '../../shared/constants'; +import {LIVENESS_CHECK, OVP_ERROR_MESSAGES} from '../../shared/constants'; import {TelemetryConstants} from '../../shared/telemetry/TelemetryConstants'; import { getImpressionEventData, @@ -23,12 +23,31 @@ import {SvgImage} from '../../components/ui/svg'; import {Loader} from '../../components/ui/Loader'; import {Icon} from 'react-native-elements'; import {ScanLayoutProps} from '../../routes/routeTypes'; +import {OpenID4VP} from '../../shared/openID4VP/OpenID4VP'; export const SendVPScreen: React.FC = props => { const {t} = useTranslation('SendVPScreen'); const controller = useSendVPScreen(); const vcsMatchingAuthRequest = controller.vcsMatchingAuthRequest; + const [triggerExitFlow, setTriggerExitFlow] = useState(false); + + useEffect(() => { + if (controller.errorModal.show && controller.isOVPViaDeepLink) { + const timeout = setTimeout(() => { + setTriggerExitFlow(true); + }, 2000); + + return () => clearTimeout(timeout); + } + }, [controller.errorModal.show, controller.isOVPViaDeepLink]); + + useEffect(() => { + if (triggerExitFlow) { + controller.GO_TO_HOME(); + BackHandler.exitApp(); + } + }, [triggerExitFlow]); useEffect(() => { sendImpressionEvent( @@ -52,6 +71,26 @@ export const SendVPScreen: React.FC = props => { }, []), ); + const handleDismiss = () => { + if (controller.isOVPViaDeepLink) { + controller.GO_TO_HOME(); + OpenID4VP.sendErrorToVerifier(OVP_ERROR_MESSAGES.DECLINED); + BackHandler.exitApp(); + } else { + controller.DISMISS(); + } + }; + + const handleRejectButtonEvent = () => { + if (controller.isOVPViaDeepLink) { + controller.GO_TO_HOME(); + OpenID4VP.sendErrorToVerifier(OVP_ERROR_MESSAGES.DECLINED); + BackHandler.exitApp(); + } else { + controller.CANCEL(); + } + }; + useLayoutEffect(() => { if (controller.showLoadingScreen) { props.navigation.setOptions({ @@ -80,7 +119,7 @@ export const SendVPScreen: React.FC = props => { ), headerLeft: () => @@ -88,12 +127,16 @@ export const SendVPScreen: React.FC = props => { ), }); } - }, [controller.showLoadingScreen, controller.vpVerifierName]); + }, [ + controller.showLoadingScreen, + controller.vpVerifierName, + controller.isOVPViaDeepLink, + ]); if (controller.showLoadingScreen) { return ( @@ -109,6 +152,17 @@ export const SendVPScreen: React.FC = props => { controller.RESET_RETRY_COUNT(); }; + const getPrimaryButtonEvent = () => { + if (controller.showConfirmationPopup && controller.isOVPViaDeepLink) { + return () => { + OpenID4VP.sendErrorToVerifier(OVP_ERROR_MESSAGES.DECLINED); + controller.GO_TO_HOME(); + BackHandler.exitApp(); + }; + } + return controller.overlayDetails?.primaryButtonEvent; + }; + const getVcKey = vcData => { return VCMetadata.fromVcMetadataString(vcData.vcMetadata).getVcKey(); }; @@ -244,7 +298,7 @@ export const SendVPScreen: React.FC = props => { type="clear" loading={controller.isCancelling} title={t('SendVcScreen:reject')} - onPress={controller.CANCEL} + onPress={handleRejectButtonEvent} /> @@ -272,7 +326,7 @@ export const SendVPScreen: React.FC = props => { controller.overlayDetails.primaryButtonTestID } primaryButtonText={controller.overlayDetails.primaryButtonText} - primaryButtonEvent={controller.overlayDetails.primaryButtonEvent} + primaryButtonEvent={getPrimaryButtonEvent()} secondaryButtonTestID={ controller.overlayDetails.secondaryButtonTestID } @@ -310,7 +364,11 @@ export const SendVPScreen: React.FC = props => { } primaryButtonEvent={controller.RETRY} textButtonTestID={'home'} - textButtonText={t('ScanScreen:status.accepted.home')} + textButtonText={ + !controller.isOVPViaDeepLink + ? t('ScanScreen:status.accepted.home') + : undefined + } textButtonEvent={handleTextButtonEvent} customImageStyles={{paddingBottom: 0, marginBottom: -6}} customStyles={{marginTop: '30%'}} diff --git a/screens/Scan/SendVPScreenController.ts b/screens/Scan/SendVPScreenController.ts index 574706a9..948a8e9c 100644 --- a/screens/Scan/SendVPScreenController.ts +++ b/screens/Scan/SendVPScreenController.ts @@ -18,6 +18,7 @@ import { selectIsGetVCsSatisfyingAuthRequest, selectIsGetVPSharingConsent, selectIsInvalidIdentity, + selectIsOVPViaDeeplink, selectIsSelectingVcs, selectIsSharingVP, selectIsShowLoadingScreen, @@ -267,6 +268,7 @@ export function useSendVPScreen() { openID4VPService, selectIsFaceVerificationConsent, ), + isOVPViaDeepLink: useSelector(openID4VPService, selectIsOVPViaDeeplink), credentials: useSelector(openID4VPService, selectCredentials), verifiableCredentialsData: useSelector( openID4VPService, diff --git a/screens/Scan/SharingStatusModal.tsx b/screens/Scan/SharingStatusModal.tsx index 4d64f8c8..7d04bbf7 100644 --- a/screens/Scan/SharingStatusModal.tsx +++ b/screens/Scan/SharingStatusModal.tsx @@ -1,15 +1,33 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import {useTranslation} from 'react-i18next'; import {Theme} from '../../components/ui/styleUtils'; import {Modal} from '../../components/ui/Modal'; -import {Pressable, Dimensions, View} from 'react-native'; +import {Pressable, Dimensions, BackHandler} from 'react-native'; import {Button, Column, Row, Text} from '../../components/ui'; import testIDProps from '../../shared/commonUtil'; import {SvgImage} from '../../components/ui/svg'; export const SharingStatusModal: React.FC = props => { const {t} = useTranslation('ScanScreen'); + const resetAndExit = () => { + BackHandler.exitApp(); + props.goToHome(); + }; + useEffect(() => { + let timeoutId: NodeJS.Timeout | undefined; + + if (props.isVisible && props.buttonStatus === 'none') { + timeoutId = setTimeout(() => { + resetAndExit(); + }, 2000); + } + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [props.isVisible, props.buttonStatus]); return ( = props => { interface SharingStatusModalProps { isVisible: boolean; testId: string; - buttonStatus?: String; + buttonStatus?: 'homeAndHistoryIcons' | 'none'; title: String; message: String; image: React.ReactElement; diff --git a/shared/Utils.ts b/shared/Utils.ts index 601f03da..79e0ee4b 100644 --- a/shared/Utils.ts +++ b/shared/Utils.ts @@ -73,3 +73,8 @@ export const formatTextWithGivenLimit = (value: string, limit: number = 15) => { } return value; }; + +export enum DEEPLINK_FLOWS { + QR_LOGIN = 'qrLoginFlow', + OVP = 'ovpFlow', +} diff --git a/shared/constants.ts b/shared/constants.ts index d782dc1c..9386b6a6 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -168,3 +168,8 @@ export const FACE_SDK_MODEL_CHECKSUM = export const EXPIRED_VC_ERROR_CODE = 'ERR_VC_EXPIRED'; export const BASE_36 = 36; + +export const OVP_ERROR_MESSAGES = { + NO_MATCHING_VCS: 'No matching credentials found to fulfill the request.', + DECLINED: 'The user has declined to share their credentials at this time.', +};