diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt index 8be4960140..ef1799cf24 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt @@ -115,6 +115,7 @@ class ClientApiViewModel @Inject internal constructor( sessionId = currentSessionId, enrolledGuid = enrolResponse.guid, subjectActions = coSyncEnrolmentRecords, + externalCredential = enrolResponse.externalCredential, ), ), ) diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt index 1a40f0eab3..ff314dc277 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.core.os.bundleOf import com.simprints.core.DeviceID import com.simprints.core.PackageVersionName +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.response.AppErrorReason import com.simprints.infra.orchestration.data.ActionResponse import com.simprints.libsimprints.Constants @@ -32,6 +33,7 @@ internal class LibSimprintsResponseMapper @Inject constructor( Constants.SIMPRINTS_DEVICE_ID to deviceId, Constants.SIMPRINTS_APP_VERSION_NAME to appVersionName, Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK to true, + HAS_CREDENTIAL to (response.externalCredential != null), ).appendDataPerContractVersion(response) { version -> when { version < VersionsList.INITIAL_REWORK -> putParcelable( @@ -42,6 +44,7 @@ internal class LibSimprintsResponseMapper @Inject constructor( else -> putString(Constants.SIMPRINTS_ENROLMENT, Enrolment(response.enrolledGuid).toJson()) } }.appendCoSyncData(response.subjectActions) + .appendExternalCredential(response.externalCredential) is ActionResponse.IdentifyActionResponse -> bundleOf( Constants.SIMPRINTS_SESSION_ID to response.sessionId, @@ -73,18 +76,7 @@ internal class LibSimprintsResponseMapper @Inject constructor( Constants.SIMPRINTS_APP_VERSION_NAME to appVersionName, Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK to true, HAS_CREDENTIAL to (response.externalCredential != null), - ).also { bundle -> - val credentialJson = response.externalCredential?.let { - JSONObject() - .also { - it.put(SCANNED_CREDENTIAL_VALUE, response.externalCredential?.value) - it.put(SCANNED_CREDENTIAL_TYPE, response.externalCredential?.type) - }.toString() - } - if (credentialJson != null) { - bundle.putString(SCANNED_CREDENTIAL, credentialJson) - } - } + ).appendExternalCredential(response.externalCredential) } is ActionResponse.VerifyActionResponse -> bundleOf( @@ -160,6 +152,18 @@ internal class LibSimprintsResponseMapper @Inject constructor( actions?.let { putString(Constants.SIMPRINTS_COSYNC_SUBJECT_ACTIONS, it) } } + private fun Bundle.appendExternalCredential(credential: ExternalCredential?) = apply { + if (credential != null) { + val credentialJson = + JSONObject() + .also { + it.put(SCANNED_CREDENTIAL_VALUE, credential.value) + it.put(SCANNED_CREDENTIAL_TYPE, credential.type) + }.toString() + putString(SCANNED_CREDENTIAL, credentialJson) + } + } + private fun AppErrorReason.libSimprintsResultCode() = when (this) { AppErrorReason.UNEXPECTED_ERROR -> Constants.SIMPRINTS_UNEXPECTED_ERROR AppErrorReason.ROOTED_DEVICE -> Constants.SIMPRINTS_ROOTED_DEVICE diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt index 3d97dbf409..830e788f23 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt @@ -142,7 +142,10 @@ internal class ClientApiViewModelTest { fun `handleEnrolResponse saves correct events`() = runTest { viewModel.handleEnrolResponse( mockRequest(), - mockk { every { guid } returns "guid" }, + mockk { + every { guid } returns "guid" + every { externalCredential } returns null + }, ) coVerify { diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt index da6769457f..0ce8ec2849 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt @@ -31,6 +31,7 @@ class CommCareResponseMapperTest { sessionId = "sessionId", enrolledGuid = "guid", subjectActions = "subjects", + externalCredential = null, ), ).getBundle(CommCareConstants.COMMCARE_BUNDLE_KEY) ?: bundleOf() diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt index e9fc4bda2d..15226739a1 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt @@ -40,6 +40,7 @@ class LibSimprintsResponseMapperTest { sessionId = "sessionId", enrolledGuid = "guid", subjectActions = "subjects", + externalCredential = null, ), ) @@ -62,6 +63,7 @@ class LibSimprintsResponseMapperTest { sessionId = "sessionId", enrolledGuid = "guid", subjectActions = "subjects", + externalCredential = null, ), ) diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt index a278c218e7..fd0bd91826 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt @@ -27,6 +27,7 @@ class OdkResponseMapperTest { sessionId = "sessionId", enrolledGuid = "guid", subjectActions = "subjects", + externalCredential = null, ), ) diff --git a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/ConsentViewModel.kt b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/ConsentViewModel.kt index 8f8606c54c..0affc90d14 100644 --- a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/ConsentViewModel.kt +++ b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/ConsentViewModel.kt @@ -82,20 +82,23 @@ internal class ConsentViewModel @Inject constructor( selectedTabIndex: Int, ): ConsentViewState { val allowParentalConsent = projectConfig.consent.allowParentalConsent + val isMultiFactorIdEnabled = projectConfig.multifactorId?.allowedExternalCredentials?.isNotEmpty() ?: false return ConsentViewState( showLogo = projectConfig.consent.displaySimprintsLogo, showParentalConsent = allowParentalConsent, consentTextBuilder = GeneralConsentTextHelper( - projectConfig.consent, - projectConfig.general.modalities, - consentType, + config = projectConfig.consent, + modalities = projectConfig.general.modalities, + consentType = consentType, + isMultiFactorIdEnabled = isMultiFactorIdEnabled, ), parentalTextBuilder = if (allowParentalConsent) { ParentalConsentTextHelper( - projectConfig.consent, - projectConfig.general.modalities, - consentType, + config = projectConfig.consent, + modalities = projectConfig.general.modalities, + consentType = consentType, + isMultiFactorIdEnabled = isMultiFactorIdEnabled, ) } else { null diff --git a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelper.kt b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelper.kt index 6fcb535638..c8e234d80e 100644 --- a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelper.kt +++ b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelper.kt @@ -10,17 +10,21 @@ internal data class GeneralConsentTextHelper( private val config: ConsentConfiguration, private val modalities: List, private val consentType: ConsentType, + private val isMultiFactorIdEnabled: Boolean, ) { // TODO All the `getString(id).format(arg,arg)` calls should be `getString(id,arg,arg)` one strings are fixed - // First argument in consent text should always be program name, second is modality specific access/use case text fun assembleText(context: Context) = StringBuilder() .apply { val modalityUseCase = getModalitySpecificUseCaseText(context, modalities) - val modalityAccess = getModalitySpecificAccessText(context, modalities) + val modalityAccess = + getModalitySpecificAccessText(context, modalities) + getMultiFactorIdAccessText(context, isMultiFactorIdEnabled) - filterAppRequestForConsent(context, consentType, config, modalityUseCase) - filterForDataSharingOptions(context, config, modalityUseCase, modalityAccess) + val requestModalityUseCase = modalityUseCase + getMultiFactorIdUseCaseText(context, isMultiFactorIdEnabled) + val dataSharingModalityUseCase = modalityUseCase + getMultiFactorIdSharingText(context, isMultiFactorIdEnabled) + + filterAppRequestForConsent(context, consentType, config, requestModalityUseCase) + filterForDataSharingOptions(context, config, dataSharingModalityUseCase, modalityAccess) }.toString() private fun StringBuilder.filterAppRequestForConsent( @@ -134,4 +138,42 @@ internal data class GeneralConsentTextHelper( Modality.FACE -> context.getString(R.string.consent_biometrics_access_face) Modality.FINGERPRINT -> context.getString(R.string.consent_biometrics_access_fingerprint) } + + private fun getMultiFactorIdUseCaseText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_general), + ).joinToString(separator = " ") + } else { + "" + } + + private fun getMultiFactorIdAccessText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_credentials_access), + ).joinToString(separator = " ") + } else { + "" + } + + private fun getMultiFactorIdSharingText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_your_id), + ).joinToString(separator = " ") + } else { + "" + } } diff --git a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelper.kt b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelper.kt index bf73ec7eb4..66ae44a2f7 100644 --- a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelper.kt +++ b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelper.kt @@ -10,17 +10,20 @@ internal data class ParentalConsentTextHelper( private val config: ConsentConfiguration, private val modalities: List, private val consentType: ConsentType, + private val isMultiFactorIdEnabled: Boolean, ) { // TODO All the `getString(id).format(arg,arg)` calls should be `getString(id,arg,arg)` one strings are fixed - // First argument in consent text should always be program name, second is modality specific access/use case text fun assembleText(context: Context): String = StringBuilder() .apply { val modalityUseCase = getModalitySpecificUseCaseText(context, modalities) - val modalityAccess = getModalitySpecificAccessText(context, modalities) + val modalityAccess = + getModalitySpecificAccessText(context, modalities) + getMultiFactorIdAccessText(context, isMultiFactorIdEnabled) - filterAppRequestForParentalConsent(context, consentType, config, modalityUseCase) - extractDataSharingOptions(context, config, modalityUseCase, modalityAccess) + val requestModalityUseCase = modalityUseCase + getMultiFactorIdUseCaseText(context, isMultiFactorIdEnabled) + val dataSharingModalityUseCase = modalityUseCase + getMultiFactorIdSharingText(context, isMultiFactorIdEnabled) + filterAppRequestForParentalConsent(context, consentType, config, requestModalityUseCase) + extractDataSharingOptions(context, config, dataSharingModalityUseCase, modalityAccess) }.toString() private fun StringBuilder.filterAppRequestForParentalConsent( @@ -136,4 +139,42 @@ internal data class ParentalConsentTextHelper( Modality.FINGERPRINT -> context.getString(R.string.consent_biometrics_access_fingerprint) else -> "" } + + private fun getMultiFactorIdAccessText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_credentials_parental_access), + ).joinToString(separator = " ") + } else { + "" + } + + private fun getMultiFactorIdUseCaseText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_parental_general), + ).joinToString(separator = " ") + } else { + "" + } + + private fun getMultiFactorIdSharingText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_parental_your_id), + ).joinToString(separator = " ") + } else { + "" + } } diff --git a/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelperTest.kt b/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelperTest.kt index 985d9c4087..cda8dba7b1 100644 --- a/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelperTest.kt +++ b/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelperTest.kt @@ -40,6 +40,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -63,6 +64,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -86,6 +88,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -109,6 +112,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -132,6 +136,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.VERIFY, + false, ).assembleText(context) val expectedString = context @@ -155,6 +160,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -178,6 +184,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -201,6 +208,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -225,6 +233,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -249,6 +258,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -273,6 +283,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -296,6 +307,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -318,6 +330,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -340,6 +353,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -362,6 +376,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -384,6 +399,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -407,6 +423,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -429,6 +446,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(generalConsentText).doesNotContainMatch("\\.\\w") @@ -448,6 +466,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(generalConsentText).doesNotContainMatch("^\\s.*") @@ -467,11 +486,84 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(generalConsentText).doesNotContain(" ") } + @Test + fun `should add multi-factor ID use case text when isMultiFactorIdEnabled is true`() { + val generalConsentText = GeneralConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = false, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.ENROL, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + val expectedText = String.format( + ", %s %s", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_general), + ) + + assertThat(generalConsentText).contains(expectedText) + } + + @Test + fun `should add multi-factor ID sharing text when isMultiFactorIdEnabled is true`() { + val generalConsentText = GeneralConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = true, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.IDENTIFY, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + // Just check for the key identifying phrase from the credentials text + assertThat(generalConsentText).contains("national ID document or other external credentials") + } + + @Test + fun `should add multi-factor ID access text when isMultiFactorIdEnabled is true`() { + val generalConsentText = GeneralConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = false, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.IDENTIFY, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + val expectedText = String.format( + ", %s", + context.getString(R.string.consent_credentials_access), + ) + + assertThat(generalConsentText).contains(expectedText) + } + private fun configWithPrompt(prompt: ConsentConfiguration.ConsentPromptConfiguration) = ConsentConfiguration( programName = PROGRAM_NAME, organizationName = ORGANIZATION_NAME, diff --git a/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelperTest.kt b/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelperTest.kt index 7fe9694c79..542eae823a 100644 --- a/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelperTest.kt +++ b/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelperTest.kt @@ -40,6 +40,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -63,6 +64,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -86,6 +88,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -109,6 +112,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -132,6 +136,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.VERIFY, + false, ).assembleText(context) val expectedString = context @@ -155,6 +160,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -178,6 +184,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -201,6 +208,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -225,6 +233,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -249,6 +258,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -273,6 +283,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -296,6 +307,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -318,6 +330,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -340,6 +353,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -362,6 +376,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -384,6 +399,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -407,6 +423,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -429,6 +446,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(parentalConsentText).doesNotContainMatch("\\.\\w") @@ -448,6 +466,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(parentalConsentText).doesNotContainMatch("^\\s.*") @@ -467,11 +486,72 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(parentalConsentText).doesNotContain(" ") } + @Test + fun `should add multi-factor ID use case text when isMultiFactorIdEnabled is true`() { + val parentalConsentText = ParentalConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = false, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.ENROL, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + assertThat(parentalConsentText).contains(context.getString(R.string.consent_credentials_parental_general)) + } + + @Test + fun `should add multi-factor ID access text when isMultiFactorIdEnabled is true`() { + val parentalConsentText = ParentalConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = false, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.IDENTIFY, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + assertThat(parentalConsentText).contains(context.getString(R.string.consent_credentials_parental_access)) + } + + @Test + fun `should add multi-factor ID sharing text when isMultiFactorIdEnabled is true`() { + val parentalConsentText = ParentalConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = true, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.IDENTIFY, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + assertThat(parentalConsentText).contains("child's national ID document or other external credentials") + } + private fun configWithPrompt(prompt: ConsentConfiguration.ConsentPromptConfiguration) = ConsentConfiguration( programName = PROGRAM_NAME, organizationName = ORGANIZATION_NAME, diff --git a/feature/enrol-last-biometric/build.gradle.kts b/feature/enrol-last-biometric/build.gradle.kts index dec9378420..c1699c9563 100644 --- a/feature/enrol-last-biometric/build.gradle.kts +++ b/feature/enrol-last-biometric/build.gradle.kts @@ -10,6 +10,7 @@ android { dependencies { implementation(project(":feature:alert")) + implementation(project(":feature:external-credential")) implementation(project(":infra:event-sync")) implementation(project(":infra:config-store")) implementation(project(":infra:config-sync")) diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricContract.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricContract.kt index a3c6c5bc94..b4c2fca5c1 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricContract.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricContract.kt @@ -1,7 +1,10 @@ package com.simprints.feature.enrollast +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +@ExcludedFromGeneratedTestCoverageReports("Data class") object EnrolLastBiometricContract { val DESTINATION = R.id.enrolLastBiometricFragment @@ -10,10 +13,12 @@ object EnrolLastBiometricContract { userId: TokenizableString, moduleId: TokenizableString, steps: List, + scannedCredential: ScannedCredential?, ) = EnrolLastBiometricParams( projectId = projectId, userId = userId, moduleId = moduleId, steps = steps, + scannedCredential = scannedCredential, ) } diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricParams.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricParams.kt index 1f38f1fb95..57040125ff 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricParams.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricParams.kt @@ -1,18 +1,22 @@ package com.simprints.feature.enrollast import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.step.StepParams import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.infra.config.store.models.FaceConfiguration import com.simprints.infra.config.store.models.Finger import com.simprints.infra.config.store.models.FingerprintConfiguration @Keep +@ExcludedFromGeneratedTestCoverageReports("Data class") data class EnrolLastBiometricParams( val projectId: String, val userId: TokenizableString, val moduleId: TokenizableString, val steps: List, + val scannedCredential: ScannedCredential?, ) : StepParams sealed class EnrolLastBiometricStepResult : StepParams { diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricResult.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricResult.kt index 872e6874c8..e7abcc16f4 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricResult.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricResult.kt @@ -1,9 +1,11 @@ package com.simprints.feature.enrollast import androidx.annotation.Keep +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.step.StepResult @Keep data class EnrolLastBiometricResult( val newSubjectId: String?, + val externalCredential: ExternalCredential?, ) : StepResult diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricFragment.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricFragment.kt index 9411e13490..b95dc0bafa 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricFragment.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricFragment.kt @@ -6,6 +6,8 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.response.AppErrorReason import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.alert.AlertContract @@ -21,6 +23,8 @@ import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.DUPLICATE_ENROLMENTS import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.GENERAL_ERROR import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.NO_MATCH_RESULTS +import com.simprints.feature.enrollast.screen.model.CredentialDialogItem +import com.simprints.feature.externalcredential.view.ScannedCredentialDialog import com.simprints.infra.config.store.models.GeneralConfiguration.Modality import com.simprints.infra.events.event.domain.models.AlertScreenEvent import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION @@ -37,6 +41,7 @@ import com.simprints.infra.resources.R as IDR internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_last) { private val viewModel: EnrolLastBiometricViewModel by viewModels() private val params: EnrolLastBiometricParams by navigationParams() + private var dialog: BottomSheetDialog? = null override fun onViewCreated( view: View, @@ -50,17 +55,35 @@ internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_las viewLifecycleOwner, R.id.enrolLastBiometricFragment, AlertContract.DESTINATION, - ) { finishWithSubjectId(null) } + ) { finish(newSubjectId = null, credential = null) } - viewModel.finish.observe(viewLifecycleOwner, LiveDataEventWithContentObserver { finishWithResult(it) }) + initObservers() viewModel.onViewCreated(params) } + private fun initObservers() { + viewModel.finish.observe(viewLifecycleOwner, LiveDataEventWithContentObserver { finishWithResult(it) }) + viewModel.showAddCredentialDialog.observe( + viewLifecycleOwner, + LiveDataEventWithContentObserver(::displayCredentialDialog), + ) + } + + private fun displayCredentialDialog(credentialDialogItem: CredentialDialogItem) { + dialog = ScannedCredentialDialog( + context = requireActivity(), + credential = credentialDialogItem.scannedCredential, + displayedCredential = credentialDialogItem.displayedCredential, + onConfirm = { viewModel.enrolBiometric(params, isAddingCredential = true) }, + onSkip = { viewModel.enrolBiometric(params, isAddingCredential = false) }, + ).also(ScannedCredentialDialog::show) + } + private fun finishWithResult(result: EnrolLastState) = when (result) { is EnrolLastState.Failed -> showError(result.errorType, result.modalities) is EnrolLastState.Success -> { Toast.makeText(requireContext(), getString(IDR.string.enrol_last_biometrics_success), Toast.LENGTH_LONG).show() - finishWithSubjectId(result.newGuid) + finish(result.newGuid, result.externalCredential) } } @@ -103,7 +126,10 @@ internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_las } }.let { getString(it) } - private fun finishWithSubjectId(newSubjectId: String?) { - findNavController().finishWithResult(this, EnrolLastBiometricResult(newSubjectId)) + private fun finish( + newSubjectId: String?, + credential: ExternalCredential?, + ) { + findNavController().finishWithResult(this, EnrolLastBiometricResult(newSubjectId, credential)) } } diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt index 0216e1849d..c580af08cb 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt @@ -4,18 +4,25 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.GENERAL_ERROR +import com.simprints.feature.enrollast.screen.model.CredentialDialogItem import com.simprints.feature.enrollast.screen.usecase.BuildSubjectUseCase import com.simprints.feature.enrollast.screen.usecase.CheckForDuplicateEnrolmentsUseCase +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.EnrolmentEventV4 import com.simprints.infra.events.session.SessionEventRepository @@ -32,21 +39,37 @@ internal class EnrolLastBiometricViewModel @Inject constructor( private val eventRepository: SessionEventRepository, private val enrolmentRecordRepository: EnrolmentRecordRepository, private val checkForDuplicateEnrolments: CheckForDuplicateEnrolmentsUseCase, + private val tokenizationProcessor: TokenizationProcessor, private val buildSubject: BuildSubjectUseCase, ) : ViewModel() { val finish: LiveData> get() = _finish - private var _finish = MutableLiveData>() + private val _finish = MutableLiveData>() + + val showAddCredentialDialog: LiveData> + get() = _showAddCredentialDialog + private val _showAddCredentialDialog = MutableLiveData>() private var enrolWasAttempted = false fun onViewCreated(params: EnrolLastBiometricParams) { - if (!enrolWasAttempted) { - enrolBiometric(params) + viewModelScope.launch { + params.scannedCredential?.let { scannedCredential -> + if (isCredentialLinkedToAnotherSubject(scannedCredential, params.projectId)) { + displayAddCredentialDialog(scannedCredential, params.projectId) + return@launch + } + } + if (!enrolWasAttempted) { + enrolBiometric(params, isAddingCredential = true) + } } } - fun enrolBiometric(params: EnrolLastBiometricParams) = viewModelScope.launch { + fun enrolBiometric( + params: EnrolLastBiometricParams, + isAddingCredential: Boolean, + ) = viewModelScope.launch { enrolWasAttempted = true val projectConfig = configManager.getProjectConfiguration() @@ -54,10 +77,11 @@ internal class EnrolLastBiometricViewModel @Inject constructor( val modalities = projectConfig.general.modalities val previousLastEnrolmentResult = getPreviousEnrolmentResult(params.steps) + val scannedCredential = params.scannedCredential?.takeIf { isAddingCredential } if (previousLastEnrolmentResult != null) { _finish.send( previousLastEnrolmentResult.subjectId - ?.let { EnrolLastState.Success(it) } + ?.let { subjectId -> EnrolLastState.Success(subjectId, scannedCredential?.toExternalCredential(subjectId)) } ?: EnrolLastState.Failed(GENERAL_ERROR, modalities), ) return@launch @@ -69,16 +93,44 @@ internal class EnrolLastBiometricViewModel @Inject constructor( } try { - val subject = buildSubject(params) + val subject = buildSubject(params, isAddingCredential = isAddingCredential) registerEvent(subject) enrolmentRecordRepository.performActions(listOf(SubjectAction.Creation(subject)), project) - _finish.send(EnrolLastState.Success(subject.subjectId)) + _finish.send(EnrolLastState.Success(subject.subjectId, scannedCredential?.toExternalCredential(subject.subjectId))) } catch (t: Throwable) { Simber.e("Enrolment failed", t, tag = ENROLMENT) _finish.send(EnrolLastState.Failed(GENERAL_ERROR, modalities)) } } + private suspend fun displayAddCredentialDialog( + scannedCredential: ScannedCredential, + projectId: String, + ) { + val project = configManager.getProject(projectId) + val decrypted = tokenizationProcessor.decrypt( + encrypted = scannedCredential.credential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) as TokenizableString.Raw + _showAddCredentialDialog.send(CredentialDialogItem(scannedCredential, decrypted)) + } + + private suspend fun isCredentialLinkedToAnotherSubject( + scannedCredential: ScannedCredential?, + projectId: String, + ): Boolean { + if (scannedCredential == null) return false + + return enrolmentRecordRepository + .load( + SubjectQuery( + projectId = projectId, + externalCredential = scannedCredential.credential, + ), + ).isNotEmpty() + } + private fun getPreviousEnrolmentResult(steps: List) = steps.filterIsInstance().firstOrNull() diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastState.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastState.kt index 538baebd41..2f5a2f7d27 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastState.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastState.kt @@ -1,17 +1,24 @@ package com.simprints.feature.enrollast.screen +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.infra.config.store.models.GeneralConfiguration +@ExcludedFromGeneratedTestCoverageReports("Data class") internal sealed class EnrolLastState { + @ExcludedFromGeneratedTestCoverageReports("Data class") data class Success( val newGuid: String, + val externalCredential: ExternalCredential?, ) : EnrolLastState() + @ExcludedFromGeneratedTestCoverageReports("Data class") data class Failed( val errorType: ErrorType, val modalities: List, ) : EnrolLastState() + @ExcludedFromGeneratedTestCoverageReports("Data class") enum class ErrorType { NO_MATCH_RESULTS, DUPLICATE_ENROLMENTS, diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/model/CredentialDialogItem.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/model/CredentialDialogItem.kt new file mode 100644 index 0000000000..afe4cc1011 --- /dev/null +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/model/CredentialDialogItem.kt @@ -0,0 +1,11 @@ +package com.simprints.feature.enrollast.screen.model + +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential + +@ExcludedFromGeneratedTestCoverageReports("Data struct") +internal data class CredentialDialogItem( + val scannedCredential: ScannedCredential, + val displayedCredential: TokenizableString.Raw, +) diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCase.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCase.kt index 8060c453a4..6341a5a27d 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCase.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCase.kt @@ -1,6 +1,5 @@ package com.simprints.feature.enrollast.screen.usecase -import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.face.FaceSample import com.simprints.core.domain.fingerprint.FingerprintSample import com.simprints.core.domain.fingerprint.IFingerIdentifier @@ -9,6 +8,8 @@ import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.FaceTemplateCaptureResult import com.simprints.feature.enrollast.FingerTemplateCaptureResult +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential import com.simprints.infra.config.store.models.Finger import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.eventsync.sync.common.SubjectFactory @@ -20,26 +21,37 @@ internal class BuildSubjectUseCase @Inject constructor( private val timeHelper: TimeHelper, private val subjectFactory: SubjectFactory, ) { - operator fun invoke(params: EnrolLastBiometricParams): Subject = subjectFactory.buildSubject( - UUID.randomUUID().toString(), - params.projectId, - params.userId, - params.moduleId, - createdAt = Date(timeHelper.now().ms), - fingerprintSamples = getFingerprintCaptureResult(params.steps) - ?.let { result -> result.results.map { fingerprintSample(result.referenceId, it) } } - .orEmpty(), - faceSamples = getFaceCaptureResult(params.steps) - ?.let { result -> result.results.map { faceSample(result.referenceId, it) } } - .orEmpty(), - externalCredentials = getExternalCredentialResult(params.steps)?.let { listOf(it) } ?: emptyList() - ) - - // TODO [CORE-3421] When an external credential can be extracted from the UI-level steps, extract it here - private fun getExternalCredentialResult(steps: List): ExternalCredential? { - return null + operator fun invoke( + params: EnrolLastBiometricParams, + isAddingCredential: Boolean, + ): Subject { + val subjectId = UUID.randomUUID().toString() + val externalCredentials = if (isAddingCredential) { + getExternalCredentialResult(params.scannedCredential, subjectId)?.let(::listOf) ?: emptyList() + } else { + emptyList() + } + return subjectFactory.buildSubject( + subjectId = subjectId, + projectId = params.projectId, + attendantId = params.userId, + moduleId = params.moduleId, + createdAt = Date(timeHelper.now().ms), + fingerprintSamples = getFingerprintCaptureResult(params.steps) + ?.let { result -> result.results.map { fingerprintSample(result.referenceId, it) } } + .orEmpty(), + faceSamples = getFaceCaptureResult(params.steps) + ?.let { result -> result.results.map { faceSample(result.referenceId, it) } } + .orEmpty(), + externalCredentials = externalCredentials, + ) } + private fun getExternalCredentialResult( + credential: ScannedCredential?, + subjectId: String, + ) = credential?.toExternalCredential(subjectId) + private fun getFingerprintCaptureResult(steps: List) = steps .filterIsInstance() .firstOrNull() diff --git a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt index e3421e3011..80f74754c8 100644 --- a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt +++ b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt @@ -10,10 +10,15 @@ import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.screen.usecase.BuildSubjectUseCase import com.simprints.feature.enrollast.screen.usecase.CheckForDuplicateEnrolmentsUseCase +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.Subject +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent.BiometricReferenceCreationPayload import com.simprints.infra.events.event.domain.models.EnrolmentEventV4 @@ -43,6 +48,9 @@ internal class EnrolLastBiometricViewModelTest { @MockK lateinit var projectConfig: ProjectConfiguration + @MockK + lateinit var project: Project + @MockK lateinit var eventRepository: SessionEventRepository @@ -58,6 +66,12 @@ internal class EnrolLastBiometricViewModelTest { @MockK lateinit var subject: Subject + @MockK + lateinit var scannedCredential: ScannedCredential + + @MockK + lateinit var tokenizationProcessor: TokenizationProcessor + private lateinit var viewModel: EnrolLastBiometricViewModel @Before @@ -75,12 +89,13 @@ internal class EnrolLastBiometricViewModelTest { ) viewModel = EnrolLastBiometricViewModel( - timeHelper, - configManager, - eventRepository, - enrolmentRecordRepository, - checkForDuplicateEnrolments, - buildSubject, + timeHelper = timeHelper, + configManager = configManager, + eventRepository = eventRepository, + enrolmentRecordRepository = enrolmentRecordRepository, + checkForDuplicateEnrolments = checkForDuplicateEnrolments, + tokenizationProcessor = tokenizationProcessor, + buildSubject = buildSubject, ) } @@ -112,13 +127,14 @@ internal class EnrolLastBiometricViewModelTest { EnrolLastBiometricStepResult.EnrolLastBiometricsResult("previousSubjectId"), ), ), + isAddingCredential = false, ) val result = viewModel.finish .test() .value() .getContentIfNotHandled() - assertThat(result).isEqualTo(EnrolLastState.Success("previousSubjectId")) + assertThat(result).isEqualTo(EnrolLastState.Success(newGuid = "previousSubjectId", externalCredential = null)) } @Test @@ -129,6 +145,7 @@ internal class EnrolLastBiometricViewModelTest { EnrolLastBiometricStepResult.EnrolLastBiometricsResult("previousSubjectId"), ), ), + isAddingCredential = false, ) coVerify(exactly = 0) { eventRepository.addOrUpdateEvent(any()) } @@ -143,6 +160,7 @@ internal class EnrolLastBiometricViewModelTest { EnrolLastBiometricStepResult.EnrolLastBiometricsResult(null), ), ), + isAddingCredential = false, ) val result = @@ -157,7 +175,7 @@ internal class EnrolLastBiometricViewModelTest { fun `returns failure when has duplicate enrolments`() = runTest { every { checkForDuplicateEnrolments.invoke(any(), any()) } returns EnrolLastState.ErrorType.DUPLICATE_ENROLMENTS - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) val result = viewModel.finish @@ -171,9 +189,9 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `returns success when no duplicate enrolments`() = runTest { every { checkForDuplicateEnrolments.invoke(any(), any()) } returns null - coEvery { buildSubject.invoke(any()) } returns subject + coEvery { buildSubject.invoke(any(), any()) } returns subject - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) val result = viewModel.finish .test() @@ -185,9 +203,9 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `saves event and record when no duplicate enrolments`() = runTest { every { checkForDuplicateEnrolments.invoke(any(), any()) } returns null - coEvery { buildSubject.invoke(any()) } returns subject + coEvery { buildSubject.invoke(any(), any()) } returns subject - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) coVerify { eventRepository.addOrUpdateEvent(any()) } coVerify { enrolmentRecordRepository.performActions(any(), any()) } @@ -196,10 +214,10 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `returns failure record saving fails`() = runTest { every { checkForDuplicateEnrolments.invoke(any(), any()) } returns null - coEvery { buildSubject.invoke(any()) } returns subject + coEvery { buildSubject.invoke(any(), any()) } returns subject coEvery { enrolmentRecordRepository.performActions(any(), any()) } throws Exception() - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) val result = viewModel.finish @@ -232,7 +250,7 @@ internal class EnrolLastBiometricViewModelTest { biometricReferenceCreationEvent1, ) - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) coVerify { eventRepository.addOrUpdateEvent( @@ -245,11 +263,39 @@ internal class EnrolLastBiometricViewModelTest { } } + @Test + fun `shows add credential dialog when scanned credential is linked to another subject`() = runTest { + val decryptedCredential = "decryptedCredential".asTokenizableRaw() + coEvery { enrolmentRecordRepository.load(any()) } returns listOf(subject) + coEvery { configManager.getProject(PROJECT_ID) } returns project + coEvery { + tokenizationProcessor.decrypt( + encrypted = scannedCredential.credential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) + } returns decryptedCredential + + viewModel.onViewCreated(createParams(listOf())) + + val result = viewModel.showAddCredentialDialog + .test() + .value() + .getContentIfNotHandled() + + assertThat(result).isNotNull() + assertThat(result?.scannedCredential).isEqualTo(scannedCredential) + assertThat(result?.displayedCredential).isEqualTo(decryptedCredential) + coVerify(exactly = 0) { buildSubject.invoke(any(), any()) } + coVerify(exactly = 0) { enrolmentRecordRepository.performActions(any(), any()) } + } + private fun createParams(steps: List) = EnrolLastBiometricParams( projectId = PROJECT_ID, userId = USER_ID, moduleId = MODULE_ID, steps = steps, + scannedCredential = scannedCredential, ) companion object { diff --git a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCaseTest.kt b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCaseTest.kt index 2145e1a2c0..c43ae557c6 100644 --- a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCaseTest.kt +++ b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCaseTest.kt @@ -1,7 +1,10 @@ package com.simprints.feature.enrollast.screen.usecase import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.externalcredential.ExternalCredential +import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.fingerprint.IFingerIdentifier +import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp @@ -9,6 +12,8 @@ import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.FaceTemplateCaptureResult import com.simprints.feature.enrollast.FingerTemplateCaptureResult +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential import com.simprints.infra.config.store.models.Finger import com.simprints.infra.eventsync.sync.common.SubjectFactory import com.simprints.testtools.unit.EncodingUtilsImplForTests @@ -23,6 +28,9 @@ class BuildSubjectUseCaseTest { @MockK private lateinit var timeHelper: TimeHelper + @MockK + private lateinit var scannedCredential: ScannedCredential + private lateinit var useCase: BuildSubjectUseCase private lateinit var subjectFactory: SubjectFactory @@ -40,7 +48,7 @@ class BuildSubjectUseCaseTest { @Test fun `has no samples if no steps provided`() { - val result = useCase(createParams(emptyList())) + val result = useCase(createParams(steps = emptyList(), scannedCredential = scannedCredential), isAddingCredential = false) assertThat(result.fingerprintSamples).isEmpty() assertThat(result.faceSamples).isEmpty() @@ -50,12 +58,14 @@ class BuildSubjectUseCaseTest { fun `has no samples if no valid steps provided`() { val result = useCase( createParams( - listOf( + steps = listOf( EnrolLastBiometricStepResult.EnrolLastBiometricsResult(null), EnrolLastBiometricStepResult.FingerprintMatchResult(emptyList(), mockk()), EnrolLastBiometricStepResult.FaceMatchResult(emptyList(), mockk()), ), + scannedCredential = scannedCredential, ), + isAddingCredential = false, ) assertThat(result.fingerprintSamples).isEmpty() @@ -66,7 +76,7 @@ class BuildSubjectUseCaseTest { fun `maps first available fingerprint capture step results`() { val result = useCase( createParams( - listOf( + steps = listOf( EnrolLastBiometricStepResult.FingerprintMatchResult(emptyList(), mockk()), EnrolLastBiometricStepResult.FingerprintCaptureResult( REFERENCE_ID, @@ -77,7 +87,9 @@ class BuildSubjectUseCaseTest { listOf(mockFingerprintResults(Finger.LEFT_THUMB)), ), ), + scannedCredential = scannedCredential, ), + isAddingCredential = false, ) assertThat(result.fingerprintSamples).isNotEmpty() @@ -89,7 +101,7 @@ class BuildSubjectUseCaseTest { val result = useCase( createParams( listOf( - EnrolLastBiometricStepResult.FingerprintCaptureResult( + element = EnrolLastBiometricStepResult.FingerprintCaptureResult( REFERENCE_ID, listOf( mockFingerprintResults(Finger.RIGHT_5TH_FINGER), @@ -105,7 +117,9 @@ class BuildSubjectUseCaseTest { ), ), ), + scannedCredential, ), + isAddingCredential = false, ) assertThat(result.fingerprintSamples).isNotEmpty() @@ -115,24 +129,57 @@ class BuildSubjectUseCaseTest { @Test fun `maps first available face capture step results`() { val result = useCase( - createParams( + params = createParams( listOf( EnrolLastBiometricStepResult.FaceMatchResult(emptyList(), mockk()), EnrolLastBiometricStepResult.FaceCaptureResult(REFERENCE_ID, mockFaceResultsList("first")), EnrolLastBiometricStepResult.FaceCaptureResult(REFERENCE_ID, mockFaceResultsList("second")), ), + scannedCredential = scannedCredential, ), + isAddingCredential = false, ) assertThat(result.faceSamples).isNotEmpty() assertThat(result.faceSamples.first().format).isEqualTo("first") } - private fun createParams(steps: List) = EnrolLastBiometricParams( + @Test + fun `includes external credential when isAddingCredential is true and scannedCredential is not null`() { + val mockTokenized = mockk() + val mockCredentialType = mockk() + + val scannedCredential = ScannedCredential( + credential = mockTokenized, + credentialType = mockCredentialType, + documentImagePath = null, + zoomedCredentialImagePath = null, + credentialBoundingBox = null, + ) + + val result = useCase(createParams(steps = emptyList(), scannedCredential = scannedCredential), isAddingCredential = true) + + assertThat(result.externalCredentials).hasSize(1) + assertThat(result.externalCredentials.first().value).isEqualTo(mockTokenized) + assertThat(result.externalCredentials.first().type).isEqualTo(mockCredentialType) + } + + @Test + fun `has no external credentials when isAddingCredential is true but scannedCredential is null`() { + val result = useCase(createParams(steps = emptyList(), scannedCredential = null), isAddingCredential = true) + + assertThat(result.externalCredentials).isEmpty() + } + + private fun createParams( + steps: List, + scannedCredential: ScannedCredential?, + ) = EnrolLastBiometricParams( projectId = PROJECT_ID, userId = USER_ID, moduleId = MODULE_ID, steps = steps, + scannedCredential = scannedCredential, ) private fun mockFingerprintResults(finger: Finger) = FingerTemplateCaptureResult(finger, byteArrayOf(), 1, "ISO_19794_2") diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt index 8942e3b815..d2584c3ed4 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt @@ -19,7 +19,7 @@ import com.simprints.feature.externalcredential.screens.search.model.ScannedCred @ExcludedFromGeneratedTestCoverageReports("Data class") data class ExternalCredentialSearchResult( val flowType: FlowType, - val scannedCredential: ScannedCredential, + val scannedCredential: ScannedCredential?, val matchResults: List, ) : StepResult { val goodMatches = matchResults.filter(CredentialMatch::isVerificationSuccessful) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt index 751dd4b5db..d7f60e8678 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -3,17 +3,22 @@ package com.simprints.feature.externalcredential.screens.controller import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.ExternalCredentialParams +import com.simprints.infra.config.sync.ConfigManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject -import com.simprints.infra.resources.R as IDR +import kotlin.collections.orEmpty @HiltViewModel -internal class ExternalCredentialViewModel @Inject internal constructor() : ViewModel() { +internal class ExternalCredentialViewModel @Inject internal constructor( + private val configManager: ConfigManager, +) : ViewModel() { private var isInitialized = false lateinit var params: ExternalCredentialParams private set @@ -28,6 +33,18 @@ internal class ExternalCredentialViewModel @Inject internal constructor() : View private val _stateLiveData = MutableLiveData(ExternalCredentialState.EMPTY) val stateLiveData: LiveData = _stateLiveData + val externalCredentialTypes: LiveData> + get() = _externalCredentialTypes + private val _externalCredentialTypes = MutableLiveData>() + + init { + viewModelScope.launch { + val config = configManager.getProjectConfiguration() + val allowedExternalCredentials = config.multifactorId?.allowedExternalCredentials.orEmpty() + _externalCredentialTypes.postValue(allowedExternalCredentials) + } + } + private fun updateState(state: (ExternalCredentialState) -> ExternalCredentialState) { this.state = state(this.state) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/DeleteScannedImageUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/DeleteScannedImageUseCase.kt deleted file mode 100644 index 130c2a69f0..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/DeleteScannedImageUseCase.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import com.simprints.core.DispatcherIO -import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID -import com.simprints.infra.logging.Simber -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import java.io.File -import javax.inject.Inject - -internal class DeleteScannedImageUseCase @Inject constructor( - @DispatcherIO private val ioDispatcher: CoroutineDispatcher, -) { - /** - * Deletes a file from the given absolute path. - * Only deletes files within the application's cache directory for security. - * @param filePath the absolute path to the file to delete - * @return true if the file was successfully deleted, false otherwise - */ - suspend operator fun invoke(filePath: String) { - withContext(ioDispatcher) { - try { - val file = File(filePath) - if (file.exists() && file.isFile) { - file.delete() - } else { - throw IllegalArgumentException("Cached OCR image [$filePath] doesn't exist") - } - } catch (e: Exception) { - Simber.e("OCR: Unable to delete cached scan file [$filePath]", e, tag = MULTI_FACTOR_ID) - throw (e) - } - } - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt index 54dce8c37a..6ab288a498 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt @@ -7,7 +7,6 @@ import android.widget.Button import android.widget.TextView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -32,7 +31,6 @@ import com.simprints.infra.resources.R as IDR @AndroidEntryPoint internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_external_credential_select) { private val mainViewModel: ExternalCredentialViewModel by activityViewModels() - private val viewModel by viewModels() private val binding by viewBinding(FragmentExternalCredentialSelectBinding::bind) private var dialog: Dialog? = null @@ -46,7 +44,6 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext Simber.i("ExternalCredentialSelectFragment started", tag = ORCHESTRATION) observeChanges() - viewModel.loadExternalCredentials() } override fun onDestroy() { @@ -85,7 +82,7 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext } private fun observeChanges() { - viewModel.externalCredentialTypes.observe(viewLifecycleOwner) { externalCredentialTypes -> + mainViewModel.externalCredentialTypes.observe(viewLifecycleOwner) { externalCredentialTypes -> updateSelectedCredentialType(null) fillRecyclerView(externalCredentialTypes) initViews(externalCredentialTypes) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectViewModel.kt deleted file mode 100644 index e55d9da033..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectViewModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.simprints.feature.externalcredential.screens.select - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.simprints.core.domain.externalcredential.ExternalCredentialType -import com.simprints.infra.config.sync.ConfigManager -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -internal class ExternalCredentialSelectViewModel @Inject internal constructor( - private val configManager: ConfigManager, -) : ViewModel() { - val externalCredentialTypes: LiveData> - get() = _externalCredentialTypes - private val _externalCredentialTypes = MutableLiveData>() - - fun loadExternalCredentials() { - viewModelScope.launch { - val config = configManager.getProjectConfiguration() - val allowedExternalCredentials = config.multifactorId?.allowedExternalCredentials.orEmpty() - _externalCredentialTypes.postValue(allowedExternalCredentials) - } - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt index d6f73c5f31..edec286386 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt @@ -1,8 +1,96 @@ package com.simprints.feature.externalcredential.screens.skip +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.R +import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialSkipBinding +import com.simprints.feature.externalcredential.ext.getCredentialTypeString +import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel +import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint +import com.simprints.infra.resources.R as IDR @AndroidEntryPoint -class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_credential_skip) +class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_credential_skip) { + private val binding by viewBinding(FragmentExternalCredentialSkipBinding::bind) + private val mainViewModel: ExternalCredentialViewModel by activityViewModels() + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + initObservers() + } + + private fun initObservers() { + mainViewModel.externalCredentialTypes.observe(viewLifecycleOwner) { credentialTypes -> + initViews(credentialTypes) + initListeners() + } + } + + private fun initViews(credentialTypes: List) = with(binding) { + val dynamicTextReasonItemMap = + mapOf( + title to IDR.string.mfid_skip_title, + skipReasonDoesNotHaveDocument to IDR.string.mfid_skip_reason_does_not_have, + skipReasonDidNotBring to IDR.string.mfid_skip_reason_did_not_bring, + skipReasonIncorrect to IDR.string.mfid_skip_reason_incorrect, + skipReasonDoesNotWantToProvide to IDR.string.mfid_skip_reason_does_not_want_to_provide, + skipReasonDamaged to IDR.string.mfid_skip_reason_damaged, + skipReasonUnableToScan to IDR.string.mfid_skip_reason_unable_to_scan, + ) + dynamicTextReasonItemMap.forEach { entry -> + val textView = entry.key + val stringRes = entry.value + val credentialText = when (credentialTypes.size) { + 1 -> resources.getCredentialTypeString(credentialTypes.first()) + else -> getString(IDR.string.mfid_type_any_document) + } + textView.text = getString(stringRes, credentialText) + } + } + + private fun initListeners() = with(binding) { + skipCredentialScanRadioGroup.setOnCheckedChangeListener { _, checkedId -> + reasonTextInputLayout.isVisible = checkedId == R.id.skipReasonOther + + val isSkipButtonEnabled = when (checkedId) { + R.id.skipReasonOther -> { + reasonTextInput.text.toString().isNotEmpty() + } + + else -> true + } + buttonSkip.isEnabled = isSkipButtonEnabled + } + reasonTextInput.addTextChangedListener( + afterTextChanged = { + buttonSkip.isEnabled = it.toString().isNotEmpty() + }, + ) + buttonGoBack.setOnClickListener { + findNavController().popBackStack() + } + + // [MS-1166] We should log skip reasons in analytics. + buttonSkip.setOnClickListener { + mainViewModel.finish( + ExternalCredentialSearchResult( + flowType = mainViewModel.params.flowType, + scannedCredential = null, + matchResults = emptyList(), + ), + ) + } + } +} diff --git a/feature/external-credential/src/main/res/layout/fragment_external_credential_skip.xml b/feature/external-credential/src/main/res/layout/fragment_external_credential_skip.xml index 1860c12b36..25d8233497 100644 --- a/feature/external-credential/src/main/res/layout/fragment_external_credential_skip.xml +++ b/feature/external-credential/src/main/res/layout/fragment_external_credential_skip.xml @@ -1,12 +1,156 @@ - + android:layout_height="match_parent" + android:background="@color/simprints_blue"> + + + android:padding="@dimen/padding_default" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="Why did you skip the document scan?" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +