diff --git a/README.md b/README.md index 7b6896c4bfb..6ef96328ed8 100644 --- a/README.md +++ b/README.md @@ -14,19 +14,31 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Infra: signed APK releases | [simcapture-github-release-signed-apk.yml](.github/workflows/simcapture-github-release-signed-apk.yml) | New file | GitHub Action to create a GitHub Release on a merge to `main` | | Infra: signed APK releases | [app/build.gradle.kts#L85](app/build.gradle.kts#L85) | Code change | App's Package ID set to `com.simprints.simcapture` | | Infra: GitHub Actions filtering | [simcapture-disable-upstream-workflows.yml](.github/workflows/simcapture-disable-upstream-workflows.yml) | New file | GitHub Action to limit allowed Actions to the ones starting with `simcapture-` or `copilot-` | -| Docs: fork-specific README | [README.md#L1-L51](README.md#L1-L51) | Code change | This section in README | -| Identification+ Enrol Last | [EnrollmentActivity.kt#L61](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L61) | Code addition | Launches Enrol Last before completing a valid enrollment save and resumes finish after SID returns | +| Docs: fork-specific README | [README.md#L1-L70](README.md#L1-L70) | Code change | This section in README | +| Enrolment+ Possible Duplicates | [CustomIntentActivityResultContract.kt#L68](form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt#L68) | Code addition | Treats non-identify Simprints callouts that return identification matches as `PossibleDuplicates` results | +| Enrolment+ Possible Duplicates | [FormView.kt#L654](form/src/main/java/org/dhis2/form/ui/FormView.kt#L654) | Code addition | Stores the returned SID session and hands the enrolment form off to the possible duplicates search flow | +| Enrolment+ Possible Duplicates | [EnrollmentActivity.kt#L281](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L281) | Code addition | Launches possible duplicates search from the enrolment form, carrying the biometric field and returned GUID matches | +| Enrolment+ Possible Duplicates | [SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt](app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt) | New file | Resolves returned GUIDs one by one into TEI search results for the possible duplicates review list | +| Enrolment+ Possible Duplicates | [SearchTEIViewModel.kt#L554](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L554) | Code addition | Switches search loading into possible-duplicates mode and auto-falls back to Enrol Last when no matching TEIs exist in DHIS2 | +| Enrolment+ Possible Duplicates | [SearchTEList.kt#L307](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L307) | Code addition | Replaces the default "+ New" action button with `None of the above` while reviewing possible duplicates | +| Enrolment+ Enrol Last | [SearchTEIViewModel.kt#L502](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L502) | Code addition | Marks `None of the above` as a pending enrolment action and closes the possible duplicates search | +| Enrolment+ Enrol Last | [SearchTEActivity.kt#L655](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L655) | Code addition | Returns possible duplicates search with an auto-enrol-last signal when the flow should continue to `REGISTER_LAST_BIOMETRICS` | +| Enrolment+ Enrol Last | [EnrollmentActivity.kt#L145](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L145) | Code addition | Receives the possible duplicates return flow and launches save-time Enrol Last before enrolment finishes | +| Enrolment+ Enrol Last | [SimprintsEnrollmentViewModel.kt#L41](app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt#L41) | Code addition | Builds the auto-enrol-last intent from the stored session and restores pending actions across lifecycle interruptions | +| Enrolment+ Enrol Last | [SimprintsSessionRepository.kt#L29](commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt#L29) | Code addition | Keeps separate pending Enrol Last source state for possible-duplicates returns | +| Enrolment+ Enrol Last | [SimprintsRememberCustomIntentFormPresenter.kt#L32](form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt#L32) | Code change | Shows `(Using last biometrics)` when Enrol Last is resumed after returning from possible duplicates | +| Identification+ Enrol Last | [EnrollmentActivity.kt#L83](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L83) | Code addition | Launches Enrol Last before completing a valid enrollment save and resumes finish after SID returns | | Identification+ Enrol Last | [EnrollmentPresenterImpl.kt#L163](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt#L163) | Code addition | Delegates save-time Enrol Last resolution and result handling | | Identification+ Enrol Last | [EnrollmentModule.kt#L182](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L182) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | | Identification+ Enrol Last | [SimprintsEnrollmentViewModelFactory.kt](app/src/main/java/org/dhis2/simprints/di/SimprintsEnrollmentViewModelFactory.kt) | New file | Creates activity-scoped Simprints Enrol Last ViewModel so pending save-time state survives configuration changes | -| Identification+ Enrol Last | [SearchTEActivity.kt#L564](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L564) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | -| Identification+ Enrol Last | [SearchTEIViewModel.kt#L737](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L737) | Code addition | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | -| Identification+ Enrol Last | [SearchTEIViewModel.kt#L1139](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1139) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | -| Identification+ Enrol Last | [SearchTEList.kt#L271](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L271) | Code addition | Observes the Simprints create-action label state for if the new TEI enrollment button in the result list should use Enrol Last | -| Identification+ Enrol Last | [SearchTEUi.kt#L668](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L668) | Code change | Switches the new TEI enrollment button text to the last-biometrics label when a pending biometric session exists | -| Identification+ Enrol Last | [FormRepositoryImpl.kt#L59](form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt#L59) | Code change | Counts pending Enrol Last values as present for form completion, sections, and mandatory validation | +| Identification+ Enrol Last | [SearchTEActivity.kt#L571](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L571) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | +| Identification+ Enrol Last | [SimprintsSearchViewModel.kt#L96](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L96) | Code addition | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | +| Identification+ Enrol Last | [SearchTEIViewModel.kt#L1266](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1266) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | +| Identification+ Enrol Last | [SearchTEList.kt#L289](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L289) | Code addition | Observes the Simprints create-action label state for if the new TEI enrollment button in the result list should use Enrol Last | +| Identification+ Enrol Last | [SearchTEUi.kt#L688](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L688) | Code change | Switches the new TEI enrollment button text to the last-biometrics label when a pending biometric session exists | +| Identification+ Enrol Last | [FormRepositoryImpl.kt#L61](form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt#L61) | Code change | Counts pending Enrol Last values as present for form completion, sections, and mandatory validation | | Identification+ Enrol Last | [Injector.kt#L268](form/src/main/java/org/dhis2/form/di/Injector.kt#L268) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | -| Identification+ Enrol Last | [CustomIntentProvider.kt#L72](form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt#L72) | Code addition | Shows pending biometric placeholder state, clears it on user clear, and routes Simprints enrollment results | +| Identification+ Enrol Last | [CustomIntentProvider.kt#L75](form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt#L75) | Code addition | Shows pending biometric placeholder state, clears it on user clear, and routes Simprints enrollment results | | Identification+ Enrol Last | [SimprintsCustomIntentResultMapper.kt](app/src/main/java/org/dhis2/simprints/SimprintsCustomIntentResultMapper.kt) | New file | Maps SID result intents through the existing custom-intent response contract | | Identification+ Enrol Last | [SimprintsEnrollmentViewModel.kt](app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt) | New file | Coordinates pending Enrol Last launch, save-time result persistence, and session clearing | | Identification+ Enrol Last | [SimprintsD2Repository.kt](commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt) | New file | Provides D2 access for enrollment context and TEA value reads/writes used by Simprints flows | @@ -37,17 +49,25 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification+ Enrol Last | [SimprintsCustomIntentFormPresenter.kt](form/src/main/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenter.kt) | New file | Handles Simprints form callout launch, session capture, placeholder display, and pending-state clearing | | Identification+ Enrol Last | [SimprintsRememberCustomIntentFormPresenter.kt](form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt) | New file | Builds the Simprints form presenter from Compose field state and placeholder resources | | Identification Confirm Identity | [SearchTEActivity.kt#L128](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L128) | Code addition | Registers the confirm-identity SID launcher and waits for Simprints navigation before opening dashboards | -| Identification Confirm Identity | [SearchTEIViewModel.kt#L743](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L743) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | +| Identification Confirm Identity | [SearchTEIViewModel.kt#L824](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L824) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | | Identification Confirm Identity | [SearchTEModule.java#L358](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L358) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | | Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L46](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L46) | Code addition | Passes Simprints search state into `SearchTEIViewModel` | | Identification Confirm Identity | [SimprintsSearchViewModelFactory.kt](app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt) | New file | Creates activity-scoped Simprints search ViewModel so Confirm Identity and Enrol Last state survives configuration changes | | Identification Confirm Identity | [SimprintsSearchViewModel.kt](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt) | New file | Coordinates biometric search handoff for Enrol Last labels and Confirm Identity navigation | | Identification Confirm Identity | [SimprintsResolveConfirmIdentityCalloutUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt) | New file | Resolves the selected TEI's biometric GUID and prepares the `CONFIRM_IDENTITY` callout | -| ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L103](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L103) | Code addition | Overrides `moduleId` for Simprints identification intents with the current user's Org Unit value | -| Identification result ordering by score | [SearchTEIViewModel.kt#L608](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L608) | Code addition | Routes biometric search loading through SID-order-aware lookup so results follow identification match score order | +| ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L105](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L105) | Code addition | Overrides `moduleId` for Simprints identification intents with the current user's Org Unit value | +| Identification result ordering by score | [SearchTEIViewModel.kt#L692](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L692) | Code addition | Routes biometric search loading through SID-order-aware lookup so results follow identification match score order | | Identification result ordering by score | [SearchTEModule.java#L350](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L350) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | | Identification result ordering by score | [SearchTeiViewModelFactory.kt#L47](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L47) | Code addition | Passes the ordered-result use case into `SearchTEIViewModel` | | Identification result ordering by score | [SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt) | New file | Orders matching TEIs to follow the SID identification response order | +| Identification with less buttons | [ParameterSelectorItemProvider.kt#L92](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt#L92) | Code addition | Starts Simprints identification callout immediately on biometric search field click instead of expanding the field | +| Identification with less buttons | [SearchTEUi.kt#L211](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L211) | Code change | Keeps the Biometric search label read-only | +| Identification with less buttons | [SearchParametersScreen.kt#L140](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt#L140) | Code addition | Treats a successful Simprints identification return as an immediate search command instead of waiting for a manual extra step | +| Identification with less buttons | [SearchTEIViewModel.kt#L1231](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1231) | Code addition | Rehydrates carried query values and performs the search immediately when biometric query data is present | +| Identification with less buttons | [SearchTEActivity.kt#L620](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L620) | Code addition | Skips the transient search editor and jumps straight to the biometric result list without backdrop animation | +| MFID Auto-Open Record | [SearchParametersScreen.kt#L498](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt#L498) | Code addition | Detects credential-linked identification results that qualify for the MFID direct-open shortcut | +| MFID Auto-Open Record | [SimprintsSearchViewModel.kt#L187](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L187) | Code addition | Consumes an eligible single MFID identification to open the matched record directly instead of showing the result list | +| MFID Auto-Open Record | [SimprintsResolveSingleBiometricSearchNavigationUseCase.kt](app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt) | New file | Resolves the biometric search to exactly one TEI before auto-opening the record | ### Releases diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index 7b216de212f..378b9204dce 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -3,11 +3,11 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent import androidx.lifecycle.ViewModel -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi @OptIn(ExperimentalAtomicApi::class) class SimprintsEnrollmentViewModel( @@ -25,14 +25,7 @@ class SimprintsEnrollmentViewModel( private val pendingAction = AtomicReference(null) - suspend fun onFinishRequested( - isNewEnrollment: Boolean, - enrollmentUid: String, - ): Intent? { - if (!isNewEnrollment) { - return null - } - + suspend fun onFinishRequested(enrollmentUid: String): Intent? { val resolvedAction = sessionRepository.pendingEnrollmentSessionId()?.let { resolvePendingEnrollmentAction( @@ -45,39 +38,77 @@ class SimprintsEnrollmentViewModel( return resolvedAction.callout.launchIntent } + suspend fun onAutoEnrollLastRequested(enrollmentUid: String): Intent? { + val sessionId = sessionRepository.get()?.takeIf(String::isNotBlank) ?: return null + val resolvedAction = + resolvePendingEnrollmentAction( + enrollmentUid = enrollmentUid, + sessionId = sessionId, + ) ?: return null + + pendingAction.store(resolvedAction) + sessionRepository.markPendingEnrollmentFromPossibleDuplicates() + return resolvedAction.callout.launchIntent + } + suspend fun onRegisterLastResult( resultCode: Int, data: Intent?, teiUid: String?, + enrollmentUid: String?, ): RegisterLastResult { - val resolvedAction = pendingAction.exchange(null) ?: return RegisterLastResult.NONE + val resolvedAction = + pendingAction.exchange(null) + ?: restorePendingAction(enrollmentUid) + ?: return RegisterLastResult.NONE + if (resultCode != RESULT_OK) { + sessionRepository.clearPendingEnrollment() + return RegisterLastResult.ERROR + } - val saved = - if (resultCode == RESULT_OK && teiUid != null) { - val value = - resultMapper.map( - responseData = resolvedAction.callout.responseData, - data = data, - ) ?: return RegisterLastResult.ERROR + return try { + val resolvedTeiUid = + teiUid ?: enrollmentUid?.let { simprintsD2Repository.getEnrollmentContext(it)?.teiUid } + if (resolvedTeiUid == null) { + sessionRepository.clearPendingEnrollment() + return RegisterLastResult.ERROR + } + val value = + resultMapper.map( + responseData = resolvedAction.callout.responseData, + data = data, + ) + if (value == null) { + sessionRepository.clearPendingEnrollment() + RegisterLastResult.ERROR + } else { simprintsD2Repository.saveTrackedEntityAttributeValue( - teiUid = teiUid, + teiUid = resolvedTeiUid, attributeUid = resolvedAction.fieldUid, value = value, ) - true - } else { - false + sessionRepository.clear() + RegisterLastResult.CONTINUE_FINISH } - - if (saved) { - sessionRepository.clear() - return RegisterLastResult.CONTINUE_FINISH + } catch (e: Exception) { + sessionRepository.clearPendingEnrollment() + throw e } - - return RegisterLastResult.ERROR } fun onRegisterLastLaunchFailed() { pendingAction.store(null) + sessionRepository.clearPendingEnrollment() + } + + private suspend fun restorePendingAction( + enrollmentUid: String?, + ): SimprintsResolvePendingEnrollmentActionUseCase.PendingEnrollmentAction? { + val resolvedEnrollmentUid = enrollmentUid?.takeIf(String::isNotBlank) ?: return null + val sessionId = sessionRepository.pendingEnrollmentSessionId()?.takeIf(String::isNotBlank) ?: return null + return resolvePendingEnrollmentAction( + enrollmentUid = resolvedEnrollmentUid, + sessionId = sessionId, + ) } } diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCase.kt b/app/src/main/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCase.kt new file mode 100644 index 00000000000..c3ef20c4ecb --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCase.kt @@ -0,0 +1,61 @@ +package org.dhis2.simprints + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.dhis2.commons.filters.sorting.SortingItem +import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase +import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +import org.dhis2.data.search.SearchParametersModel +import org.dhis2.form.model.FieldUiModel +import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt +import org.dhis2.usescases.searchTrackEntity.SearchTeiModel + +class SimprintsLoadBiometricSearchResultsUseCase( + private val searchRepository: SearchRepository, + private val searchRepositoryKt: SearchRepositoryKt, + private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase, +) { + suspend operator fun invoke( + searchItems: List, + searchParametersModel: SearchParametersModel, + isOnline: Boolean, + offlineOnly: Boolean, + sortingItem: SortingItem?, + ): Flow>? { + val trackedEntities = + orderSearchResultsByIdentifyResponse( + searchFields = searchItems.toSearchFields(), + queryData = searchParametersModel.queryData.orEmpty(), + searchTrackedEntities = { + searchRepositoryKt.searchTrackedEntitiesImmediate( + searchParametersModel = searchParametersModel, + isOnline = isOnline, + ) + }, + ) ?: return null + + return flowOf( + PagingData.from( + trackedEntities.map { searchItem -> + searchRepository.transform( + searchItem, + searchParametersModel.selectedProgram, + offlineOnly, + sortingItem, + ) + }, + ), + ) + } + + private fun List.toSearchFields(): List = + map { field -> + SimprintsSearchUtils.SearchField( + uid = field.uid, + value = field.value, + customIntent = field.customIntent, + ) + } +} diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt b/app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt new file mode 100644 index 00000000000..21cb57382e6 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt @@ -0,0 +1,64 @@ +package org.dhis2.simprints + +import org.dhis2.commons.filters.sorting.SortingItem +import org.dhis2.data.search.SearchParametersModel +import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt +import org.dhis2.usescases.searchTrackEntity.SearchTeiModel + +class SimprintsLoadPossibleDuplicatesSearchResultsUseCase( + private val searchRepository: SearchRepository, + private val searchRepositoryKt: SearchRepositoryKt, +) { + suspend operator fun invoke( + searchParametersModel: SearchParametersModel, + isOnline: Boolean, + offlineOnly: Boolean, + sortingItem: SortingItem?, + ): List? { + val simprintsQueryData = searchParametersModel.queryData.orEmpty() + val simprintsQueryEntry = + simprintsQueryData.entries.firstOrNull { (_, values) -> + (values?.size ?: 0) > 1 + } ?: simprintsQueryData.entries.firstOrNull { (_, values) -> + !values.isNullOrEmpty() + } ?: return null + + val simprintsQueryValues = + simprintsQueryEntry + .value + .orEmpty() + .filter(String::isNotBlank) + .distinct() + .takeIf(List::isNotEmpty) + ?: return null + + val searchItems = + buildList { + simprintsQueryValues.forEach { guidValue -> + addAll( + searchRepositoryKt.searchTrackedEntitiesImmediate( + searchParametersModel = + searchParametersModel.copy( + queryData = + simprintsQueryData.toMutableMap().apply { + put(simprintsQueryEntry.key, listOf(guidValue)) + }, + ), + isOnline = isOnline, + ), + ) + } + }.distinctBy { it.uid() } + + return searchItems.map { searchItem -> + searchRepository.transform( + searchItem, + searchParametersModel.selectedProgram, + offlineOnly, + sortingItem, + ) + } + } +} + diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt b/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt new file mode 100644 index 00000000000..841c29e797e --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt @@ -0,0 +1,70 @@ +package org.dhis2.simprints + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.dhis2.commons.filters.FilterManager +import org.dhis2.commons.network.NetworkUtils +import org.dhis2.data.search.SearchParametersModel +import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt + +class SimprintsResolveSingleBiometricSearchNavigationUseCase( + private val searchRepository: SearchRepository, + private val searchRepositoryKt: SearchRepositoryKt, + private val networkUtils: NetworkUtils, + private val filterManager: FilterManager, + private val ioDispatcher: CoroutineDispatcher, +) { + data class NavigationTarget( + val teiUid: String, + val programUid: String?, + val enrollmentUid: String?, + val isOnline: Boolean, + ) + + suspend operator fun invoke( + initialProgramUid: String?, + queryData: Map?>, + value: String?, + ): NavigationTarget? = + withContext(ioDispatcher) { + val biometricSearchValues = + value + ?.split(",") + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?: return@withContext null + + if (biometricSearchValues.size != 1) { + return@withContext null + } + + val searchParametersModel = + SearchParametersModel( + selectedProgram = searchRepository.getProgram(initialProgramUid), + queryData = queryData.toMutableMap(), + ) + val isOnline = queryData.isNotEmpty() && networkUtils.isOnline() + val trackedEntity = + searchRepositoryKt + .searchTrackedEntitiesImmediate( + searchParametersModel = searchParametersModel, + isOnline = isOnline, + ).singleOrNull() ?: return@withContext null + + val searchTeiModel = + searchRepository.transform( + trackedEntity, + searchParametersModel.selectedProgram, + !(isOnline && filterManager.stateFilters.isEmpty()), + filterManager.sortingItem, + ) + + NavigationTarget( + teiUid = searchTeiModel.uid(), + programUid = searchTeiModel.selectedEnrollment?.program() ?: initialProgramUid, + enrollmentUid = searchTeiModel.selectedEnrollment?.uid(), + isOnline = searchTeiModel.isOnline, + ) + } +} diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index 9e1a97edd08..deb8cb82ac2 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -2,22 +2,37 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +import org.dhis2.form.model.FieldUiModel +import org.dhis2.form.model.FieldUiModelImpl +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi @OptIn(ExperimentalAtomicApi::class) class SimprintsSearchViewModel( private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase, private val sessionRepository: SimprintsSessionRepository, + private val resolveSingleBiometricSearchNavigation: SimprintsResolveSingleBiometricSearchNavigationUseCase, ) : ViewModel() { + private data class PendingSimprintsMfidBiometricIdentification( + val uid: String, + val value: String?, + ) + data class PendingDashboardNavigation( val teiUid: String, val programUid: String?, val enrollmentUid: String?, + val isOnline: Boolean = false, ) sealed class DashboardAction { @@ -31,22 +46,38 @@ class SimprintsSearchViewModel( } private val pendingDashboardNavigation = AtomicReference(null) + private val pendingSimprintsMfidBiometricIdentification = + AtomicReference(null) + private var useLastBiometricsForSequentialSearch = false + private val _simprintsBiometricSearchNavigation = Channel(Channel.BUFFERED) + val simprintsBiometricSearchNavigation: Flow = + _simprintsBiometricSearchNavigation.receiveAsFlow() + private val _isSimprintsBiometricSearch = MutableLiveData(false) + val isSimprintsBiometricSearch: LiveData = _isSimprintsBiometricSearch + private val _isSimprintsUseLastBiometricsLabel = MutableLiveData(false) + val isSimprintsUseLastBiometricsLabel: LiveData = _isSimprintsUseLastBiometricsLabel suspend fun onDashboardRequested( - searchFields: List, + searchItems: List, teiUid: String, programUid: String?, enrollmentUid: String?, + keepSession: Boolean = false, ): DashboardAction { + val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) + val useSequentialSearchLastBiometrics = shouldUseSequentialSearchLastBiometrics(searchState) val sessionId = - sessionRepository.get()?.takeIf { searchState.hasBiometricIdentificationQuery } + sessionRepository + .get() + ?.takeIf { searchState.hasBiometricIdentificationQuery || useSequentialSearchLastBiometrics } val confirmIdentityIntent = sessionId?.let { resolveConfirmIdentityCallout( teiUid = teiUid, searchFields = searchFields, sessionId = it, + allowBlankSearchValue = useSequentialSearchLastBiometrics, )?.launchIntent } @@ -61,17 +92,25 @@ class SimprintsSearchViewModel( } pendingDashboardNavigation.store(navigation) - sessionRepository.clear() + if (!keepSession) { + pendingSimprintsMfidBiometricIdentification.store(null) + useLastBiometricsForSequentialSearch = false + sessionRepository.clear() + } return DashboardAction.LaunchConfirmIdentity(confirmIdentityIntent) } fun prepareEnrollmentQueryData( - searchFields: List, + searchItems: List, queryData: Map?>, ): HashMap> { + val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) - if (searchState.hasBiometricIdentificationQuery && sessionRepository.hasPendingSession()) { + if ( + sessionRepository.hasPendingSession() && + (searchState.hasBiometricIdentificationQuery || shouldUseSequentialSearchLastBiometrics(searchState)) + ) { sessionRepository.markPendingEnrollment() } @@ -81,26 +120,177 @@ class SimprintsSearchViewModel( ) } + fun markPendingEnrollmentFromSimprintsPossibleDuplicates() { + sessionRepository.markPendingEnrollmentFromPossibleDuplicates() + } + fun onConfirmIdentityResult(resultCode: Int): PendingDashboardNavigation? = - pendingDashboardNavigation.exchange(null) + pendingDashboardNavigation + .exchange(null) ?.takeIf { resultCode == RESULT_OK } fun onConfirmIdentityLaunchFailed() { pendingDashboardNavigation.store(null) } - fun clearPendingSessionIfNeeded(searchFields: List) { + fun clearPendingSession() { + pendingDashboardNavigation.store(null) + pendingSimprintsMfidBiometricIdentification.store(null) + useLastBiometricsForSequentialSearch = false + sessionRepository.clear() + } + + fun clearPendingSessionIfNeeded(searchItems: List) { + val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) - if (sessionRepository.hasPendingSession() && searchState.shouldClearPendingSession) { + if ( + sessionRepository.hasPendingSession() && + searchState.shouldClearPendingSession && + !shouldUseSequentialSearchLastBiometrics(searchState) + ) { + pendingSimprintsMfidBiometricIdentification.store(null) + useLastBiometricsForSequentialSearch = false sessionRepository.clear() } } - fun shouldUseLastBiometricsLabel(searchFields: List): Boolean { + fun refreshSimprintsUiState(searchItems: List) { + clearPendingSessionIfNeeded(searchItems) + val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) + _isSimprintsBiometricSearch.postValue(searchState.hasBiometricIdentificationQuery) + _isSimprintsUseLastBiometricsLabel.postValue( + SimprintsSearchUtils.shouldUseLastBiometricsLabel( + searchState = searchState, + hasPendingSession = sessionRepository.hasPendingSession(), + ) || shouldUseSequentialSearchLastBiometrics(searchState), + ) + } + + fun onSimprintsBiometricIdentificationResult( + uid: String, + value: String?, + hasAutoOpenEligibleSimprintsIdentification: Boolean, + ) { + useLastBiometricsForSequentialSearch = false + pendingSimprintsMfidBiometricIdentification.store( + if (!value.isNullOrBlank() && hasAutoOpenEligibleSimprintsIdentification) { + PendingSimprintsMfidBiometricIdentification(uid = uid, value = value) + } else { + null + }, + ) + } + + suspend fun onSimprintsParameterSaved( + uid: String, + value: String?, + searchItems: List, + initialProgramUid: String?, + queryData: Map?>, + ): PendingDashboardNavigation? { + val searchFields = searchItems.toSearchFields() + + if (consumePendingSimprintsMfidBiometricIdentification(uid, value)) { + return resolveSingleBiometricSearchNavigation( + initialProgramUid = initialProgramUid, + queryData = queryData, + value = value, + )?.let { navigationTarget -> + PendingDashboardNavigation( + teiUid = navigationTarget.teiUid, + programUid = navigationTarget.programUid, + enrollmentUid = navigationTarget.enrollmentUid, + isOnline = navigationTarget.isOnline, + ) + }?.also { + clearPendingSession() + } ?: requestSimprintsBiometricSearchNavigation() + } + + if (!shouldAutoNavigateToSimprintsBiometricSearch(uid, value, searchFields)) { + return null + } + + requestSimprintsBiometricSearchNavigation() + return null + } + + fun clearSimprintsBiometricQueryData( + searchItems: List, + queryData: MutableMap?>, + ): List? { + val simprintsBiometricFieldUids = + searchItems + .filter { field -> + SimprintsIntentUtils.isIdentifyCallout(field.customIntent) + }.map(FieldUiModel::uid) + .toSet() + + if (simprintsBiometricFieldUids.isEmpty()) { + return null + } + + queryData.keys.removeAll(simprintsBiometricFieldUids) + useLastBiometricsForSequentialSearch = sessionRepository.hasPendingSession() + + return searchItems.map { field -> + if (field.uid in simprintsBiometricFieldUids) { + (field as FieldUiModelImpl).copy(value = null, displayName = null) + } else { + field + } + }.also(::refreshSimprintsUiState) + } + + fun shouldUseLastBiometricsLabel(searchItems: List): Boolean { + val searchState = SimprintsSearchUtils.searchState(searchItems.toSearchFields()) return SimprintsSearchUtils.shouldUseLastBiometricsLabel( searchState = searchState, hasPendingSession = sessionRepository.hasPendingSession(), - ) + ) || shouldUseSequentialSearchLastBiometrics(searchState) + } + + private fun shouldUseSequentialSearchLastBiometrics( + searchState: SimprintsSearchUtils.SearchState, + ): Boolean = + useLastBiometricsForSequentialSearch && + searchState.shouldClearPendingSession && + sessionRepository.hasPendingSession() + + private fun shouldAutoNavigateToSimprintsBiometricSearch( + uid: String, + value: String?, + searchFields: List, + ): Boolean = + !value.isNullOrBlank() && + searchFields + .firstOrNull { it.uid == uid } + ?.customIntent + ?.let(SimprintsIntentUtils::isIdentifyCallout) == true + + private fun consumePendingSimprintsMfidBiometricIdentification( + uid: String, + value: String?, + ): Boolean { + val pending = pendingSimprintsMfidBiometricIdentification.load() + + return pending?.uid == uid && + pending.value == value && + pendingSimprintsMfidBiometricIdentification.compareAndSet(pending, null) + } + + private suspend fun requestSimprintsBiometricSearchNavigation(): PendingDashboardNavigation? { + _simprintsBiometricSearchNavigation.send(Unit) + return null } + + private fun List.toSearchFields(): List = + map { field -> + SimprintsSearchUtils.SearchField( + uid = field.uid, + value = field.value, + customIntent = field.customIntent, + ) + } } diff --git a/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt b/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt index feb4faa0c9c..e9391116356 100644 --- a/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt @@ -4,15 +4,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase +import org.dhis2.simprints.SimprintsResolveSingleBiometricSearchNavigationUseCase import org.dhis2.simprints.SimprintsSearchViewModel class SimprintsSearchViewModelFactory( private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase, private val sessionRepository: SimprintsSessionRepository, + private val resolveSingleBiometricSearchNavigation: SimprintsResolveSingleBiometricSearchNavigationUseCase, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) as T } diff --git a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt index 55c2664d632..9c3b0e3c87c 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt @@ -27,13 +27,17 @@ import org.dhis2.form.data.GeometryController import org.dhis2.form.data.GeometryParserImpl import org.dhis2.form.model.EventMode import org.dhis2.form.ui.FormView +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract +import org.dhis2.form.ui.customintent.CustomIntentResult +import org.dhis2.form.ui.customintent.CustomIntentResultContext import org.dhis2.maps.views.MapSelectorActivity +import org.dhis2.simprints.SimprintsEnrollmentViewModel.RegisterLastResult import org.dhis2.usescases.events.ScheduledEventActivity import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventCaptureActivity import org.dhis2.usescases.general.ActivityGlobalAbstract +import org.dhis2.usescases.searchTrackEntity.SearchTEActivity import org.dhis2.usescases.teiDashboard.TeiDashboardMobileActivity import org.dhis2.utils.granularsync.OPEN_ERROR_LOCATION -import org.dhis2.simprints.SimprintsEnrollmentViewModel.RegisterLastResult import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.enrollment.EnrollmentStatus import timber.log.Timber @@ -58,6 +62,29 @@ class EnrollmentActivity : lateinit var binding: EnrollmentActivityBinding lateinit var mode: EnrollmentMode + private val simprintsCustomIntentActivityResultContract = CustomIntentActivityResultContract() + private var pendingSimprintsCustomIntentResultContext: CustomIntentResultContext? = null + private val simprintsCustomIntentLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val resultContext = pendingSimprintsCustomIntentResultContext + pendingSimprintsCustomIntentResultContext = null + try { + supportFragmentManager.executePendingTransactions() + formView.handleCustomIntentResult( + simprintsCustomIntentActivityResultContract.parseResult( + resultCode = result.resultCode, + intent = result.data, + resultContext = resultContext, + ), + ) + } catch (e: Exception) { + Timber.e(e) + displayMessage(getString(custom_intent_error)) + if (::formView.isInitialized) { + formView.reload() + } + } + } private val simprintsRegisterLastBiometricsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> lifecycleScope.launch { @@ -66,6 +93,7 @@ class EnrollmentActivity : presenter.onRegisterLastResult( resultCode = result.resultCode, data = result.data, + enrollmentUid = intent.getStringExtra(ENROLLMENT_UID_EXTRA), ) ) { RegisterLastResult.CONTINUE_FINISH -> { @@ -89,6 +117,79 @@ class EnrollmentActivity : } } + private val simprintsAutoRegisterLastBiometricsLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + lifecycleScope.launch { + try { + when ( + presenter.onRegisterLastResult( + resultCode = result.resultCode, + data = result.data, + enrollmentUid = intent.getStringExtra(ENROLLMENT_UID_EXTRA), + ) + ) { + RegisterLastResult.CONTINUE_FINISH -> { + formView.reload() + } + + RegisterLastResult.ERROR -> { + displayMessage(getString(custom_intent_error)) + } + + RegisterLastResult.NONE -> { + // no-op + } + } + } catch (e: Exception) { + Timber.e(e) + displayMessage(getString(custom_intent_error)) + } + } + } + + private val simprintsPossibleDuplicatesSearchLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val shouldAutoEnrollLast = + result.resultCode == RESULT_OK && + result.data?.getBooleanExtra( + SearchTEActivity.SIMPRINTS_AUTO_ENROLL_LAST_BIOMETRICS_RESULT, + false, + ) == true + if (!shouldAutoEnrollLast) { + return@registerForActivityResult + } + + val enrollmentUid = intent.getStringExtra(ENROLLMENT_UID_EXTRA).orEmpty() + if (enrollmentUid.isBlank()) { + displayMessage(getString(custom_intent_error)) + return@registerForActivityResult + } + + lifecycleScope.launch { + val simprintsRegisterLastIntent = + try { + presenter.onSimprintsAutoEnrollLastRequested(enrollmentUid) + } catch (e: Exception) { + Timber.e(e) + displayMessage(getString(custom_intent_error)) + return@launch + } + + if (simprintsRegisterLastIntent == null) { + displayMessage(getString(custom_intent_error)) + return@launch + } + + try { + simprintsAutoRegisterLastBiometricsLauncher.launch(simprintsRegisterLastIntent) + } catch (e: Exception) { + Timber.e(e) + presenter.onRegisterLastLaunchFailed() + displayMessage(getString(custom_intent_error)) + } + } + } + companion object { const val ENROLLMENT_UID_EXTRA = "ENROLLMENT_UID_EXTRA" const val PROGRAM_UID_EXTRA = "PROGRAM_UID_EXTRA" @@ -97,6 +198,8 @@ class EnrollmentActivity : const val RQ_ENROLLMENT_GEOMETRY = 1023 const val RQ_INCIDENT_GEOMETRY = 1024 const val RQ_EVENT = 1025 + private const val SIMPRINTS_CUSTOM_INTENT_RESULT_CONTEXT_STATE = + "SIMPRINTS_CUSTOM_INTENT_RESULT_CONTEXT_STATE" fun getIntent( context: Context, @@ -137,6 +240,12 @@ class EnrollmentActivity : super.onCreate(savedInstanceState) + pendingSimprintsCustomIntentResultContext = + CustomIntentResultContext.restoreFrom( + savedState = savedInstanceState, + keyPrefix = SIMPRINTS_CUSTOM_INTENT_RESULT_CONTEXT_STATE, + ) + if (presenter.getEnrollment() == null || presenter.getEnrollment()?.trackedEntityInstance() == null ) { @@ -167,12 +276,45 @@ class EnrollmentActivity : ), locationProvider = locationProvider, dateEditionWarningHandler = dateEditionWarningHandler, + onLaunchSimprintsCustomIntent = { input -> + pendingSimprintsCustomIntentResultContext = CustomIntentResultContext.from(input) + try { + simprintsCustomIntentLauncher.launch( + simprintsCustomIntentActivityResultContract.createIntent(this, input), + ) + } catch (e: Exception) { + pendingSimprintsCustomIntentResultContext = null + Timber.e(e) + displayMessage(getString(custom_intent_error)) + if (::formView.isInitialized) { + formView.handleCustomIntentResult( + CustomIntentResult.Error(fieldUid = input.fieldUid), + ) + } + } + }, + onLaunchSimprintsPossibleDuplicatesSearch = { search -> + val teiTypeToAdd = presenter.getProgram()?.trackedEntityType()?.uid() + if (teiTypeToAdd.isNullOrBlank()) { + Timber.e("Failed to launch Simprints Possible duplicates search: tracked entity type is missing") + displayMessage(getString(custom_intent_error)) + return@buildEnrollmentForm + } + simprintsPossibleDuplicatesSearchLauncher.launch( + SearchTEActivity.getSimprintsPossibleDuplicatesIntent( + context = this, + programUid = programUid, + teiTypeToAdd = teiTypeToAdd, + fieldUid = search.fieldUid, + guidValues = search.guidValues, + ), + ) + }, ) { lifecycleScope.launch { val simprintsRegisterLastIntent = try { presenter.onFinishRequested( - isNewEnrollment = enrollmentMode == EnrollmentMode.NEW, enrollmentUid = enrollmentUid, ) } catch (e: Exception) { @@ -199,6 +341,14 @@ class EnrollmentActivity : presenter.init() } + override fun onSaveInstanceState(outState: Bundle) { + pendingSimprintsCustomIntentResultContext?.saveTo( + outState = outState, + keyPrefix = SIMPRINTS_CUSTOM_INTENT_RESULT_CONTEXT_STATE, + ) + super.onSaveInstanceState(outState) + } + override fun onResume() { presenter.subscribeToBackButton() super.onResume() diff --git a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt index 20c09a508f6..0aa05a6b0e4 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt @@ -160,23 +160,26 @@ class EnrollmentPresenterImpl( } } - suspend fun onFinishRequested( - isNewEnrollment: Boolean, - enrollmentUid: String, - ): Intent? = + suspend fun onFinishRequested(enrollmentUid: String): Intent? = simprintsEnrollmentViewModel.onFinishRequested( - isNewEnrollment = isNewEnrollment, + enrollmentUid = enrollmentUid, + ) + + suspend fun onSimprintsAutoEnrollLastRequested(enrollmentUid: String): Intent? = + simprintsEnrollmentViewModel.onAutoEnrollLastRequested( enrollmentUid = enrollmentUid, ) suspend fun onRegisterLastResult( resultCode: Int, data: Intent?, + enrollmentUid: String?, ): SimprintsEnrollmentViewModel.RegisterLastResult = simprintsEnrollmentViewModel.onRegisterLastResult( resultCode = resultCode, data = data, teiUid = getEnrollment()?.trackedEntityInstance(), + enrollmentUid = enrollmentUid ?: getEnrollment()?.uid(), ) fun onRegisterLastLaunchFailed() { diff --git a/app/src/main/java/org/dhis2/usescases/enrollment/FormInjector.kt b/app/src/main/java/org/dhis2/usescases/enrollment/FormInjector.kt index f155da179b3..818d05f4a9b 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/FormInjector.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/FormInjector.kt @@ -7,9 +7,11 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.floatingactionbutton.FloatingActionButton import org.dhis2.R import org.dhis2.commons.locationprovider.LocationProvider +import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSearchUseCase.SimprintsPossibleDuplicatesSearch import org.dhis2.form.model.EnrollmentMode import org.dhis2.form.model.EnrollmentRecords import org.dhis2.form.ui.FormView +import org.dhis2.form.ui.customintent.CustomIntentInput data class EnrollmentFormBuilderConfig( val enrollmentUid: String, @@ -26,6 +28,8 @@ fun AppCompatActivity.buildEnrollmentForm( config: EnrollmentFormBuilderConfig, locationProvider: LocationProvider, dateEditionWarningHandler: DateEditionWarningHandler, + onLaunchSimprintsCustomIntent: ((CustomIntentInput) -> Unit)? = null, + onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)? = null, onFinish: () -> Unit, ): FormView = FormView @@ -46,16 +50,20 @@ fun AppCompatActivity.buildEnrollmentForm( ) } }.onFinishDataEntry(onFinish) - .factory(supportFragmentManager) - .setRecords( - EnrollmentRecords( - enrollmentUid = config.enrollmentUid, - enrollmentMode = config.enrollmentMode, - ), - ).openErrorLocation(config.openErrorLocation) - .setProgramUid(config.programUid) - .build() - .also { formView -> + .let { builder -> + onLaunchSimprintsCustomIntent?.let(builder::onLaunchSimprintsCustomIntent) + onLaunchSimprintsPossibleDuplicatesSearch?.let(builder::onLaunchSimprintsPossibleDuplicatesSearch) + builder + .factory(supportFragmentManager) + .setRecords( + EnrollmentRecords( + enrollmentUid = config.enrollmentUid, + enrollmentMode = config.enrollmentMode, + ), + ).openErrorLocation(config.openErrorLocation) + .setProgramUid(config.programUid) + .build() + }.also { formView -> config.saveButton.setOnClickListener { formView.onSaveClick() } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt index 67cdbb9410c..9c93bc87b68 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -80,8 +80,8 @@ import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme +import org.json.JSONObject import timber.log.Timber -import java.io.Serializable import javax.inject.Inject class SearchTEActivity : @@ -130,6 +130,8 @@ class SearchTEActivity : viewModel.onConfirmIdentityResult(result.resultCode) } + private var simprintsKeepSessionOnFinish = false + private var initSearchNeeded = true var searchComponent: SearchTEComponent? = null @@ -169,6 +171,9 @@ class SearchTEActivity : if (currentScreen.isNotBlank()) { currentContent = Content.valueOf(currentScreen) } + viewModel.setSimprintsPossibleDuplicatesSearch( + intent.getBooleanExtra(SIMPRINTS_POSSIBLE_DUPLICATES_SEARCH, false), + ) initSearchParameters() searchScreenConfigurator = @@ -211,6 +216,7 @@ class SearchTEActivity : observeScreenState() observeDownload() observeLegacyInteractions() + observeSimprintsBiometricSearchNavigation() observeSimprintsNavigation() if (intent.shouldLaunchSyncDialog()) { @@ -269,6 +275,13 @@ class SearchTEActivity : } override fun onDestroy() { + if ( + isFinishing && + intent.getBooleanExtra(SIMPRINTS_POSSIBLE_DUPLICATES_SEARCH, false) && + !simprintsKeepSessionOnFinish + ) { + viewModel.clearSimprintsSession() + } if (sessionManagerServiceImpl.isUserLoggedIn()) { presenter.onDestroy() FilterManager.getInstance().clearEnrollmentStatus() @@ -309,7 +322,7 @@ class SearchTEActivity : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putSerializable(Constants.QUERY_DATA, viewModel.queryData as Serializable) + outState.putString(Constants.QUERY_DATA, JSONObject(viewModel.queryData).toString()) outState.putString(CURRENT_SCREEN, currentContent?.name) } @@ -601,6 +614,17 @@ class SearchTEActivity : } } + private fun observeSimprintsBiometricSearchNavigation() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.simprintsBiometricSearchNavigation.collect { + searchScreenConfigurator.closeBackdrop(withAnimation = false) + viewModel.onSimprintsBiometricSearchNavigation() + } + } + } + } + private fun observeSimprintsNavigation() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -627,6 +651,20 @@ class SearchTEActivity : is SimprintsNavigationAction.ShowMessage -> { displayMessage(action.message) } + + is SimprintsNavigationAction.AutoEnrollLastBiometricsFromSimprintsPossibleDuplicates -> { + simprintsKeepSessionOnFinish = true + setResult( + RESULT_OK, + Intent().putExtra(SIMPRINTS_AUTO_ENROLL_LAST_BIOMETRICS_RESULT, true), + ) + finish() + } + + is SimprintsNavigationAction.FinishSimprintsPossibleDuplicatesSearch -> { + simprintsKeepSessionOnFinish = true + finish() + } } } } @@ -814,6 +852,8 @@ class SearchTEActivity : companion object { private const val CURRENT_SCREEN = "current_screen" + private const val SIMPRINTS_POSSIBLE_DUPLICATES_SEARCH = "SIMPRINTS_POSSIBLE_DUPLICATES_SEARCH" + const val SIMPRINTS_AUTO_ENROLL_LAST_BIOMETRICS_RESULT = "SIMPRINTS_AUTO_ENROLL_LAST_BIOMETRICS_RESULT" fun getIntent( context: Context?, @@ -831,5 +871,20 @@ class SearchTEActivity : intent.putExtras(extras) return intent } + + fun getSimprintsPossibleDuplicatesIntent( + context: Context, + programUid: String, + teiTypeToAdd: String, + fieldUid: String, + guidValues: List, + ): Intent = + Intent(context, SearchTEActivity::class.java).apply { + putExtra(Extra.TEI_UID.key, teiTypeToAdd) + putExtra(Extra.PROGRAM_UID.key, programUid) + putStringArrayListExtra(Extra.QUERY_ATTR.key, arrayListOf(fieldUid)) + putStringArrayListExtra(Extra.QUERY_VALUES.key, arrayListOf(guidValues.joinToString(","))) + putExtra(SIMPRINTS_POSSIBLE_DUPLICATES_SEARCH, true) + } } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt index cb431d18684..7aefc1d7cc0 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow @@ -44,8 +43,7 @@ import org.dhis2.commons.extensions.toPercentage import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase -import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel import org.dhis2.form.model.FieldUiModel @@ -58,8 +56,10 @@ import org.dhis2.maps.layer.basemaps.BaseMapStyle import org.dhis2.maps.managers.MapManager import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.mobile.commons.coroutine.CoroutineTracker -import org.dhis2.tracker.NavigationBarUIState +import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase +import org.dhis2.simprints.SimprintsLoadPossibleDuplicatesSearchResultsUseCase import org.dhis2.simprints.SimprintsSearchViewModel +import org.dhis2.tracker.NavigationBarUIState import org.dhis2.usescases.searchTrackEntity.listView.SearchResult import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState import org.dhis2.usescases.searchTrackEntity.ui.UnableToSearchOutsideData @@ -88,6 +88,10 @@ sealed class SimprintsNavigationAction { data class ShowMessage( val message: String, ) : SimprintsNavigationAction() + + object FinishSimprintsPossibleDuplicatesSearch : SimprintsNavigationAction() + + object AutoEnrollLastBiometricsFromSimprintsPossibleDuplicates : SimprintsNavigationAction() } class SearchTEIViewModel( @@ -104,7 +108,7 @@ class SearchTEIViewModel( private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, private val simprintsSearchViewModel: SimprintsSearchViewModel, - private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase, + private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase, ) : ViewModel() { private var layersVisibility: Map = emptyMap() @@ -142,8 +146,14 @@ class SearchTEIViewModel( val createButtonScrollVisibility = MutableLiveData(false) val isScrollingDown = MutableLiveData(false) - private val _isSimprintsUseLastBiometricsLabel = MutableLiveData(false) - val isSimprintsUseLastBiometricsLabel: LiveData = _isSimprintsUseLastBiometricsLabel + val simprintsBiometricSearchNavigation: Flow = + simprintsSearchViewModel.simprintsBiometricSearchNavigation + val isSimprintsBiometricSearch: LiveData = + simprintsSearchViewModel.isSimprintsBiometricSearch + val isSimprintsUseLastBiometricsLabel: LiveData = + simprintsSearchViewModel.isSimprintsUseLastBiometricsLabel + private val _isSimprintsPossibleDuplicatesSearch = MutableLiveData(false) + val isSimprintsPossibleDuplicatesSearch: LiveData = _isSimprintsPossibleDuplicatesSearch private var searching: Boolean = false private val filtersActive = MutableLiveData(false) @@ -171,6 +181,12 @@ class SearchTEIViewModel( private val onNewSearch = MutableSharedFlow(extraBufferCapacity = 1) + private var simprintsPossibleDuplicatesAutoNoneOfAboveTriggered: Boolean = false + private var keepSearchScreenOpenForSimprintsBiometricNoMatches: Boolean = false + + private val loadSimprintsPossibleDuplicatesSearchResultsUseCase = + SimprintsLoadPossibleDuplicatesSearchResultsUseCase(searchRepository, searchRepositoryKt) + val searchPagingData = onNewSearch .onStart { emit(Unit) } @@ -204,6 +220,14 @@ class SearchTEIViewModel( refreshSimprintsUiState() } + fun clearSimprintsSession() { + simprintsSearchViewModel.clearPendingSession() + } + + fun setSimprintsPossibleDuplicatesSearch(isSimprintsPossibleDuplicatesSearch: Boolean) { + _isSimprintsPossibleDuplicatesSearch.value = isSimprintsPossibleDuplicatesSearch + } + private fun loadNavigationBarItems() { CoroutineTracker.increment() val pageConfigurator = searchNavPageConfigurator.initVariables() @@ -266,11 +290,13 @@ class SearchTEIViewModel( } val displayFrontPageList = searchRepository.getProgram(initialProgramUid)?.displayFrontPageList() ?: true - val shouldOpenSearch = + val shouldForceSearch = !displayFrontPageList && !searchRepository.canCreateInProgramWithoutSearch() && !searching && filtersActive.value == false + val shouldOpenSearch = + shouldForceSearch || keepSearchScreenOpenForSimprintsBiometricNoMatches createButtonScrollVisibility.postValue( if (searching) { @@ -297,7 +323,7 @@ class SearchTEIViewModel( .getProgram(initialProgramUid) ?.minAttributesRequiredToSearch() ?: 1, - isForced = shouldOpenSearch, + isForced = shouldForceSearch, isOpened = shouldOpenSearch, ), searchFilters = @@ -446,12 +472,48 @@ class SearchTEIViewModel( } fun clearQueryData() { + clearSimprintsBiometricNoMatchesState() queryData.clear() clearSearchParameters() + simprintsSearchViewModel.clearPendingSession() updateSearch() performSearch() } + fun clearSimprintsBiometricQueryData() { + val updatedItems = + simprintsSearchViewModel.clearSimprintsBiometricQueryData( + searchItems = searchParametersUiState.items, + queryData = queryData, + ) ?: return + + searchParametersUiState = + searchParametersUiState.copy( + items = updatedItems, + clearSearchEnabled = queryData.isNotEmpty(), + ) + searchParametersUiState = + searchParametersUiState.copy( + searchedItems = getFriendlyQueryData(), + ) + updateSearch() + } + + fun onSimprintsPossibleDuplicatesNoneOfAboveClick() { + simprintsSearchViewModel.markPendingEnrollmentFromSimprintsPossibleDuplicates() + viewModelScope.launch { + _simprintsNavigation.send(SimprintsNavigationAction.FinishSimprintsPossibleDuplicatesSearch) + } + } + + fun onSimprintsBiometricSearchNavigation() { + clearSimprintsBiometricNoMatchesState() + onNavigationPageChanged(NavigationPage.LIST_VIEW) + setListScreen() + searchRepository.clearFetchedList() + performSearch() + } + private fun clearSearchParameters() { val updatedItems = searchParametersUiState.items.map { @@ -460,6 +522,7 @@ class SearchTEIViewModel( searchParametersUiState = searchParametersUiState.copy( items = updatedItems, + clearSearchEnabled = false, searchedItems = mapOf(), ) searching = false @@ -488,14 +551,34 @@ class SearchTEIViewModel( selectedProgram = searchRepository.getProgram(initialProgramUid), queryData = queryData, ) - loadSimprintsBiometricSearchResults( - searchParametersModel = searchParametersModel, - isOnline = searching && networkUtils.isOnline(), - )?.let { return@withContext it } + val isSimprintsPossibleDuplicatesSearch = _isSimprintsPossibleDuplicatesSearch.value == true + val isOnline = searching && networkUtils.isOnline() + + if (isSimprintsPossibleDuplicatesSearch) { + val simprintsPossibleDuplicatesResults = + loadSimprintsPossibleDuplicatesSearchResultsUseCase( + searchParametersModel = searchParametersModel, + isOnline = isOnline, + offlineOnly = !(isOnline && filterManager.stateFilters.isEmpty()), + sortingItem = filterManager.sortingItem, + ) ?: emptyList() + + if (simprintsPossibleDuplicatesResults.isEmpty() && !simprintsPossibleDuplicatesAutoNoneOfAboveTriggered) { + simprintsPossibleDuplicatesAutoNoneOfAboveTriggered = true + onSimprintsPossibleDuplicatesAutoEnrollLastClick() + } + + return@withContext flow { emit(PagingData.from(simprintsPossibleDuplicatesResults)) } + } else { + loadSimprintsBiometricSearchResults( + searchParametersModel = searchParametersModel, + isOnline = isOnline, + )?.let { return@withContext it } + } val getPagingData = searchRepositoryKt.searchTrackedEntities( searchParametersModel, - searching && networkUtils.isOnline(), + isOnline, ) return@withContext getPagingData.map { pagingData -> @@ -525,6 +608,12 @@ class SearchTEIViewModel( } } + private fun onSimprintsPossibleDuplicatesAutoEnrollLastClick() { + viewModelScope.launch { + _simprintsNavigation.send(SimprintsNavigationAction.AutoEnrollLastBiometricsFromSimprintsPossibleDuplicates) + } + } + private suspend fun loadDisplayInListResults() = withContext(dispatchers.io()) { val searchParametersModel = @@ -604,29 +693,14 @@ class SearchTEIViewModel( searchParametersModel: SearchParametersModel, isOnline: Boolean, ): Flow>? { - val trackedEntities = - orderSearchResultsByIdentifyResponse( - searchFields = getSimprintsSearchFields(), - queryData = searchParametersModel.queryData, - searchTrackedEntities = { - searchRepositoryKt.searchTrackedEntitiesImmediate( - searchParametersModel = searchParametersModel, - isOnline = isOnline, - ) - }, - ) ?: return null val offlineOnly = !(isOnline && filterManager.stateFilters.isEmpty()) - val orderedResults = - trackedEntities.map { searchItem -> - searchRepository.transform( - searchItem, - searchParametersModel.selectedProgram, - offlineOnly, - filterManager.sortingItem, - ) - } - - return flowOf(PagingData.from(orderedResults)) + return loadSimprintsBiometricSearchResultsUseCase( + searchItems = searchParametersUiState.items, + searchParametersModel = searchParametersModel, + isOnline = isOnline, + offlineOnly = offlineOnly, + sortingItem = filterManager.sortingItem, + ) } fun fetchMapResults() { @@ -650,6 +724,7 @@ class SearchTEIViewModel( } fun onSearch() { + clearSimprintsBiometricNoMatchesState() searchRepository.clearFetchedList() performSearch() } @@ -666,7 +741,10 @@ class SearchTEIViewModel( ) when (_screenState.value?.screenState) { - SearchScreenState.LIST -> { + SearchScreenState.LIST, + SearchScreenState.NONE, + null, + -> { setListScreen() onNewSearch.emit(Unit) } @@ -702,7 +780,10 @@ class SearchTEIViewModel( } } - private fun canPerformSearch(): Boolean = minAttributesToSearchCheck() || displayFrontPageList() + private fun canPerformSearch(): Boolean = + (_isSimprintsPossibleDuplicatesSearch.value == true && queryData.isNotEmpty()) || + minAttributesToSearchCheck() || + displayFrontPageList() private fun minAttributesToSearchCheck(): Boolean = searchRepository.getProgram(initialProgramUid)?.let { program -> @@ -736,7 +817,7 @@ class SearchTEIViewModel( fun prepareEnrollmentQueryData(queryData: Map?>): HashMap> = simprintsSearchViewModel.prepareEnrollmentQueryData( - searchFields = getSimprintsSearchFields(), + searchItems = searchParametersUiState.items, queryData = queryData, ) @@ -750,10 +831,11 @@ class SearchTEIViewModel( when ( val action = simprintsSearchViewModel.onDashboardRequested( - searchFields = getSimprintsSearchFields(), + searchItems = searchParametersUiState.items, teiUid = teiUid, programUid = programUid, enrollmentUid = enrollmentUid, + keepSession = _isSimprintsPossibleDuplicatesSearch.value == true, ) ) { is SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity -> { @@ -803,22 +885,31 @@ class SearchTEIViewModel( simprintsSearchViewModel.onConfirmIdentityLaunchFailed() } - fun refreshSimprintsUiState() { - val searchFields = getSimprintsSearchFields() - simprintsSearchViewModel.clearPendingSessionIfNeeded(searchFields) - _isSimprintsUseLastBiometricsLabel.postValue( - simprintsSearchViewModel.shouldUseLastBiometricsLabel(searchFields), + fun onSimprintsBiometricIdentificationResult( + uid: String, + value: String?, + hasAutoOpenEligibleSimprintsIdentification: Boolean, + ) { + clearSimprintsBiometricNoMatchesState() + simprintsSearchViewModel.onSimprintsBiometricIdentificationResult( + uid = uid, + value = value, + hasAutoOpenEligibleSimprintsIdentification = hasAutoOpenEligibleSimprintsIdentification, ) } - private fun getSimprintsSearchFields(): List = - searchParametersUiState.items.map { field -> - SimprintsSearchUtils.SearchField( - uid = field.uid, - value = field.value, - customIntent = field.customIntent, - ) - } + fun onSimprintsBiometricNoMatches() { + keepSearchScreenOpenForSimprintsBiometricNoMatches = true + setSearchScreen() + } + + fun clearSimprintsBiometricNoMatchesState() { + keepSearchScreenOpenForSimprintsBiometricNoMatches = false + } + + fun refreshSimprintsUiState() { + simprintsSearchViewModel.refreshSimprintsUiState(searchParametersUiState.items) + } fun onEnrollClick() { _legacyInteraction.postValue(LegacyInteraction.OnEnrollClick(queryData)) @@ -1128,14 +1219,50 @@ class SearchTEIViewModel( fetchJob = viewModelScope.launch { val fieldUiModels = - searchRepositoryKt.searchParameters(programUid, teiTypeUid) - searchParametersUiState = searchParametersUiState.copy( - items = preserveExistingSearchParameterValues(fieldUiModels), - ) + hydrateInitialQueryInFields( + searchRepositoryKt.searchParameters(programUid, teiTypeUid), + ) + searchParametersUiState = + searchParametersUiState.copy( + items = preserveExistingSearchParameterValues(fieldUiModels), + ) refreshSimprintsUiState() + + if (queryData.isNotEmpty()) { + updateSearch() + searchParametersUiState = + searchParametersUiState.copy( + clearSearchEnabled = true, + searchedItems = getFriendlyQueryData(), + ) + if (_screenState.value?.screenState == null) { + setListScreen() + } + performSearch() + } } } + private fun hydrateInitialQueryInFields(fieldUiModels: List): List = + fieldUiModels.map { fieldUiModel -> + val values = queryData[fieldUiModel.uid] + if (fieldUiModel !is FieldUiModelImpl || values.isNullOrEmpty()) { + fieldUiModel + } else { + val joinedValues = values.joinToString(",") + fieldUiModel.copy( + value = joinedValues, + displayName = + displayNameProvider.provideDisplayName( + valueType = fieldUiModel.valueType, + value = joinedValues, + optionSet = fieldUiModel.optionSet, + periodType = fieldUiModel.periodSelector?.type, + ), + ) + } + } + private fun preserveExistingSearchParameterValues(fieldUiModels: List): List { val currentItemsByUid = searchParametersUiState.items.associateBy(FieldUiModel::uid) return fieldUiModels.map { fieldUiModel -> @@ -1165,6 +1292,25 @@ class SearchTEIViewModel( formIntent.uid, formIntent.value?.split(","), ) + viewModelScope.launch { + simprintsSearchViewModel + .onSimprintsParameterSaved( + uid = formIntent.uid, + value = formIntent.value, + searchItems = searchParametersUiState.items, + initialProgramUid = initialProgramUid, + queryData = queryData, + )?.let { navigation -> + onNavigationPageChanged(NavigationPage.LIST_VIEW) + setListScreen() + clearQueryData() + onTeiClick( + teiUid = navigation.teiUid, + enrollmentUid = navigation.enrollmentUid, + online = navigation.isOnline, + ) + } + } } is FormIntent.OnQrCodeScanned -> { @@ -1292,6 +1438,18 @@ class SearchTEIViewModel( searchParametersUiState.items .filter { !it.value.isNullOrEmpty() } .forEach { item -> + if (SimprintsIntentUtils.isIdentifyCallout(item.customIntent)) { + val isSimprintsPossibleDuplicatesSearch = _isSimprintsPossibleDuplicatesSearch.value == true + map[item.uid] = + resourceManager.getString( + if (isSimprintsPossibleDuplicatesSearch) { + R.string.simprints_possible_duplicates + } else { + R.string.simprints_biometric_search + }, + ) + return@forEach + } when (item.valueType) { ValueType.ORGANISATION_UNIT, ValueType.MULTI_TEXT -> { diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java index bb531b8105e..8117a68464e 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -66,8 +66,10 @@ import org.dhis2.mobile.commons.customintents.CustomIntentRepository; import org.dhis2.mobile.commons.customintents.CustomIntentRepositoryImpl; import org.dhis2.mobile.commons.reporting.CrashReportController; +import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase; import org.dhis2.tracker.data.ProfilePictureProvider; import org.dhis2.ui.ThemeManager; +import org.dhis2.simprints.SimprintsResolveSingleBiometricSearchNavigationUseCase; import org.dhis2.simprints.di.SimprintsSearchViewModelFactory; import org.dhis2.usescases.events.EventInfoProvider; import org.dhis2.usescases.searchTrackEntity.ui.mapper.TEICardMapper; @@ -357,11 +359,45 @@ SimprintsOrderSearchResultsByIdentifyResponseUseCase provideSimprintsOrderSearch @PerActivity SimprintsSearchViewModelFactory provideSimprintsSearchViewModelFactory( SimprintsResolveConfirmIdentityCalloutUseCase resolveConfirmIdentityCalloutUseCase, - SimprintsSessionRepository simprintsSessionRepository + SimprintsSessionRepository simprintsSessionRepository, + SimprintsResolveSingleBiometricSearchNavigationUseCase resolveSingleBiometricSearchNavigationUseCase ) { return new SimprintsSearchViewModelFactory( resolveConfirmIdentityCalloutUseCase, - simprintsSessionRepository + simprintsSessionRepository, + resolveSingleBiometricSearchNavigationUseCase + ); + } + + @Provides + @PerActivity + SimprintsResolveSingleBiometricSearchNavigationUseCase provideSimprintsResolveSingleBiometricSearchNavigationUseCase( + SearchRepository searchRepository, + SearchRepositoryKt searchRepositoryKt, + NetworkUtils networkUtils, + FilterManager filterManager, + DispatcherProvider dispatcherProvider + ) { + return new SimprintsResolveSingleBiometricSearchNavigationUseCase( + searchRepository, + searchRepositoryKt, + networkUtils, + filterManager, + dispatcherProvider.io() + ); + } + + @Provides + @PerActivity + SimprintsLoadBiometricSearchResultsUseCase provideSimprintsLoadBiometricSearchResultsUseCase( + SearchRepository searchRepository, + SearchRepositoryKt searchRepositoryKt, + SimprintsOrderSearchResultsByIdentifyResponseUseCase orderSearchResultsByIdentifyResponse + ) { + return new SimprintsLoadBiometricSearchResultsUseCase( + searchRepository, + searchRepositoryKt, + orderSearchResultsByIdentifyResponse ); } @@ -378,7 +414,7 @@ SearchTeiViewModelFactory providesViewModelFactory( FilterManager filterManager, ProgramConfigurationRepository programConfigurationRepository, SimprintsSearchViewModelFactory simprintsSearchViewModelFactory, - SimprintsOrderSearchResultsByIdentifyResponseUseCase orderSearchResultsByIdentifyResponse + SimprintsLoadBiometricSearchResultsUseCase loadSimprintsBiometricSearchResultsUseCase ) { return new SearchTeiViewModelFactory( searchRepository, @@ -400,7 +436,7 @@ SearchTeiViewModelFactory providesViewModelFactory( filterManager, (SearchTEActivity) moduleContext, simprintsSearchViewModelFactory, - orderSearchResultsByIdentifyResponse + loadSimprintsBiometricSearchResultsUseCase ); } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt index 63d2a8a8c25..90968c923ac 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt @@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModelProvider import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.usecases.MapStyleConfiguration +import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase import org.dhis2.simprints.SimprintsSearchViewModel import org.dhis2.simprints.di.SimprintsSearchViewModelFactory @@ -27,7 +27,7 @@ class SearchTeiViewModelFactory( private val filterManager: FilterManager, private val searchActivity: SearchTEActivity, private val simprintsSearchViewModelFactory: SimprintsSearchViewModelFactory, - private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase, + private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = SearchTEIViewModel( @@ -44,6 +44,6 @@ class SearchTeiViewModelFactory( displayNameProvider, filterManager, ViewModelProvider(searchActivity, simprintsSearchViewModelFactory)[SimprintsSearchViewModel::class.java], - orderSearchResultsByIdentifyResponse, + loadSimprintsBiometricSearchResultsUseCase, ) as T } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt index b30ac0f1224..2fa18b89414 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt @@ -42,6 +42,7 @@ import org.dhis2.usescases.searchTrackEntity.SearchTeiViewModelFactory import org.dhis2.usescases.searchTrackEntity.adapters.SearchTeiLiveAdapter import org.dhis2.usescases.searchTrackEntity.ui.CreateNewButton import org.dhis2.usescases.searchTrackEntity.ui.FullSearchButtonAndWorkingList +import org.dhis2.usescases.searchTrackEntity.ui.SimprintsNoneOfTheAboveButton import org.dhis2.usescases.searchTrackEntity.ui.mapper.TEICardMapper import org.dhis2.utils.isLandscape import javax.inject.Inject @@ -229,6 +230,12 @@ class SearchTEList : FragmentGlobalAbstract() { remember(viewModel.searchParametersUiState) { viewModel.searchParametersUiState.searchedItems } + val isSimprintsBiometricSearch by viewModel + .isSimprintsBiometricSearch + .observeAsState(false) + val isSimprintsPossibleDuplicatesSearch by viewModel + .isSimprintsPossibleDuplicatesSearch + .observeAsState(false) FullSearchButtonAndWorkingList( teTypeName = teTypeName!!, @@ -241,8 +248,19 @@ class SearchTEList : FragmentGlobalAbstract() { onEnrollClick = { viewModel.onEnrollClick() }, onCloseFilters = { viewModel.onFiltersClick(isLandscape()) }, onClearSearchQuery = { - viewModel.clearQueryData() + if (isSimprintsPossibleDuplicatesSearch) { + requireActivity().finish() + } else { + viewModel.clearQueryData() + viewModel.clearFocus() + } + }, + isSimprintsBiometricSearch = isSimprintsBiometricSearch, + isSimprintsPossibleDuplicatesSearch = isSimprintsPossibleDuplicatesSearch, + onSimprintsBiometricSearchFallbackClick = { + viewModel.clearSimprintsBiometricQueryData() viewModel.clearFocus() + viewModel.setSearchScreen() }, workingListViewModel = workingListViewModel, ) @@ -271,6 +289,9 @@ class SearchTEList : FragmentGlobalAbstract() { val isSimprintsUseLastBiometricsLabel by viewModel .isSimprintsUseLastBiometricsLabel .observeAsState(false) + val isSimprintsPossibleDuplicatesSearch by viewModel + .isSimprintsPossibleDuplicatesSearch + .observeAsState(false) updateLayoutParams { val bottomMargin = @@ -283,7 +304,17 @@ class SearchTEList : FragmentGlobalAbstract() { } val orientation = LocalConfiguration.current.orientation - if ((hasQueryData || orientation == Configuration.ORIENTATION_LANDSCAPE) && + if (isSimprintsPossibleDuplicatesSearch && + hasQueryData && + !filtersOpened && + !teTypeName.isNullOrBlank() + ) { + SimprintsNoneOfTheAboveButton( + modifier = Modifier, + onClick = viewModel::onSimprintsPossibleDuplicatesNoneOfAboveClick, + ) + } else if ( + (hasQueryData || orientation == Configuration.ORIENTATION_LANDSCAPE) && createButtonVisibility && !filtersOpened && !teTypeName.isNullOrBlank() @@ -326,9 +357,7 @@ class SearchTEList : FragmentGlobalAbstract() { if (searchResult.shouldClearGlobalData()) { globalAdapter.refresh() } - if (searchResult.type == SearchResult.SearchResultType.TOO_MANY_RESULTS) { - listAdapter.removeAdapter(liveAdapter) - } + updateProgramResultsAdapter(searchResult) displayResult(it) updateRecycler() } @@ -357,6 +386,14 @@ class SearchTEList : FragmentGlobalAbstract() { ) } + private fun updateProgramResultsAdapter(searchResult: SearchResult) { + if (searchResult.type == SearchResult.SearchResultType.TOO_MANY_RESULTS) { + listAdapter.removeAdapter(liveAdapter) + } else if (!listAdapter.adapters.contains(liveAdapter)) { + listAdapter.addAdapter(1, liveAdapter) + } + } + private fun restoreAdapters() { if (!listAdapter.adapters.contains(liveAdapter)) { listAdapter.addAdapter(1, liveAdapter) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt index 3189760befc..9a865c8539a 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt @@ -1,7 +1,10 @@ package org.dhis2.usescases.searchTrackEntity.searchparameters +import android.app.Activity.RESULT_OK +import android.content.Intent import android.content.res.Configuration import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,10 +25,15 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -38,6 +46,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.journeyapps.barcodescanner.ScanOptions import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -45,11 +55,16 @@ import org.dhis2.R import org.dhis2.commons.Constants import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.form.data.scan.ScanContract +import org.dhis2.form.di.Injector import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FieldUiModelImpl +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope import org.dhis2.usescases.searchTrackEntity.SearchTEIViewModel import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState @@ -64,12 +79,15 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.Radius import org.hisp.dhis.mobile.ui.designsystem.theme.Shape import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor +import timber.log.Timber @Composable fun SearchParametersScreen( resourceManager: ResourceManager, uiState: SearchParametersUiState, intentHandler: (FormIntent) -> Unit, + onSimprintsBiometricIdentificationResult: (String, String?, Boolean) -> Unit, + onSimprintsBiometricNoMatches: () -> Unit, onShowOrgUnit: ( uid: String, preselectedOrgUnits: List, @@ -83,7 +101,68 @@ fun SearchParametersScreen( val snackBarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current + val context = LocalContext.current.applicationContext val configuration = LocalConfiguration.current + var showSimprintsBiometricNoMatchesMessage by rememberSaveable { mutableStateOf(false) } + var pendingSimprintsFieldUid by rememberSaveable { mutableStateOf(null) } + var pendingSimprintsValueTypeName by rememberSaveable { mutableStateOf(null) } + var pendingSimprintsResponseDataJson by rememberSaveable { mutableStateOf(null) } + var pendingSimprintsCapturesSessionId by rememberSaveable { mutableStateOf(false) } + + val simprintsSessionRepository = + remember(context) { + Injector.provideSimprintsSessionRepository(context) + } + val simprintsHasAutoOpenEligibleIdentificationUseCase = + remember { + SimprintsHasAutoOpenEligibleIdentificationUseCase() + } + val simprintsIdentifyLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val uid = pendingSimprintsFieldUid + val valueType = + pendingSimprintsValueTypeName + ?.let(ValueType::valueOf) + val returnedValue = + mapPendingSimprintsSearchResult( + responseDataJson = pendingSimprintsResponseDataJson, + resultCode = result.resultCode, + data = result.data, + capturesSessionId = pendingSimprintsCapturesSessionId, + sessionRepository = simprintsSessionRepository, + ) + + pendingSimprintsFieldUid = null + pendingSimprintsValueTypeName = null + pendingSimprintsResponseDataJson = null + pendingSimprintsCapturesSessionId = false + + val shouldShowNoMatchesMessage = + shouldShowSimprintsBiometricNoMatchesMessage( + resultCode = result.resultCode, + returnedValue = returnedValue, + ) + if (uid != null && result.resultCode == RESULT_OK && returnedValue != null) { + showSimprintsBiometricNoMatchesMessage = false + onSimprintsBiometricIdentificationResult( + uid, + returnedValue, + simprintsHasAutoOpenEligibleIdentificationUseCase(result.data?.extras), + ) + intentHandler( + FormIntent.OnSave( + uid = uid, + value = returnedValue, + valueType = valueType, + ), + ) + } else { + if (shouldShowNoMatchesMessage) { + onSimprintsBiometricNoMatches() + } + showSimprintsBiometricNoMatchesMessage = shouldShowNoMatchesMessage + } + } val scanContract = remember { ScanContract() } val qrScanLauncher = @@ -162,6 +241,7 @@ fun SearchParametersScreen( LaunchedEffect(uiState.isOnBackPressed) { uiState.isOnBackPressed.collectLatest { if (it) { + showSimprintsBiometricNoMatchesMessage = false focusManager.clearFocus() onClose() } @@ -256,6 +336,20 @@ fun SearchParametersScreen( focusManager = focusManager, fieldUiModel = fieldUiModel, callback = callback, + onSimprintsBiometricIdentificationLaunch = { + uid, + valueType, + responseDataJson, + capturesSessionId, + launchIntent, + -> + showSimprintsBiometricNoMatchesMessage = false + pendingSimprintsFieldUid = uid + pendingSimprintsValueTypeName = valueType?.name + pendingSimprintsResponseDataJson = responseDataJson + pendingSimprintsCapturesSessionId = capturesSessionId + simprintsIdentifyLauncher.launch(launchIntent) + }, onNextClicked = { val nextIndex = index + 1 if (nextIndex < uiState.items.size) { @@ -265,6 +359,16 @@ fun SearchParametersScreen( ), ) } + + if (showSimprintsBiometricNoMatchesMessage) { + item { + Text( + text = resourceManager.getString(R.string.simprints_biometric_search_no_matches), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + color = Color.Black.copy(alpha = 0.6f), + ) + } + } } if (uiState.clearSearchEnabled) { @@ -286,6 +390,7 @@ fun SearchParametersScreen( }, ) { focusManager.clearFocus() + showSimprintsBiometricNoMatchesMessage = false onClear() } } @@ -317,6 +422,7 @@ fun SearchParametersScreen( ) }, ) { + showSimprintsBiometricNoMatchesMessage = false focusManager.clearFocus() onSearch() } @@ -347,6 +453,8 @@ fun SearchFormPreview() { }, ), intentHandler = {}, + onSimprintsBiometricIdentificationResult = { _, _, _ -> }, + onSimprintsBiometricNoMatches = {}, onShowOrgUnit = { _, _, _, _ -> }, onSearch = {}, onClear = {}, @@ -354,6 +462,49 @@ fun SearchFormPreview() { ) } +private fun mapPendingSimprintsSearchResult( + responseDataJson: String?, + resultCode: Int, + data: Intent?, + capturesSessionId: Boolean, + sessionRepository: org.dhis2.commons.simprints.repository.SimprintsSessionRepository, +): String? { + if (resultCode != RESULT_OK) { + return null + } + + val responseData = + responseDataJson + ?.let { + try { + Gson().fromJson>( + it, + object : TypeToken>() {}.type, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse CustomIntentResponseDataModel") + null + } + } ?: return null + + val returnedValue = + CustomIntentActivityResultContract() + .mapIntentResponseData(responseData, data) + ?.takeUnless(List::isEmpty) + ?.joinToString(separator = ",") ?: return null + + if (capturesSessionId) { + SimprintsIntentUtils.extractSessionId(data?.extras)?.let(sessionRepository::save) + } + + return returnedValue +} + +internal fun shouldShowSimprintsBiometricNoMatchesMessage( + resultCode: Int, + returnedValue: String?, +): Boolean = resultCode == RESULT_OK && returnedValue == null + @Preview(showBackground = true) @Composable fun SearchFormPreviewWithClear() { @@ -378,6 +529,8 @@ fun SearchFormPreviewWithClear() { }, ), intentHandler = {}, + onSimprintsBiometricIdentificationResult = { _, _, _ -> }, + onSimprintsBiometricNoMatches = {}, onShowOrgUnit = { _, _, _, _ -> }, onSearch = {}, onClear = {}, @@ -409,13 +562,18 @@ fun initSearchScreen( uiState = viewModel.searchParametersUiState, onSearch = viewModel::onSearch, intentHandler = viewModel::onParameterIntent, + onSimprintsBiometricIdentificationResult = viewModel::onSimprintsBiometricIdentificationResult, + onSimprintsBiometricNoMatches = viewModel::onSimprintsBiometricNoMatches, onShowOrgUnit = onShowOrgUnit, onClear = { onClear() viewModel.clearQueryData() viewModel.clearFocus() }, - onClose = { viewModel.clearFocus() }, + onClose = { + viewModel.clearSimprintsBiometricNoMatchesState() + viewModel.clearFocus() + }, ) } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt index 1709f044e45..e83a3dc0ee9 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt @@ -1,5 +1,6 @@ package org.dhis2.usescases.searchTrackEntity.searchparameters.provider +import android.content.Intent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material.icons.outlined.QrCode2 @@ -11,10 +12,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import com.google.gson.Gson import org.dhis2.R import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.form.di.Injector import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.UiRenderType +import org.dhis2.form.simprints.rememberSimprintsCustomIntentFormPresenter import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.provider.inputfield.FieldProvider import org.hisp.dhis.android.core.common.ValueType @@ -30,8 +36,20 @@ fun provideParameterSelectorItem( fieldUiModel: FieldUiModel, callback: FieldUiModel.Callback, onNextClicked: () -> Unit, + onSimprintsBiometricIdentificationLaunch: (String, ValueType?, String?, Boolean, Intent) -> Unit, ): ParameterSelectorItemModel { val focusRequester = remember { FocusRequester() } + val context = LocalContext.current.applicationContext + val simprintsSessionRepository = + remember(context) { + Injector.provideSimprintsSessionRepository(context) + } + val simprintsCustomIntentFormPresenter = + rememberSimprintsCustomIntentFormPresenter( + fieldUiModel = fieldUiModel, + resources = resources, + sessionRepository = simprintsSessionRepository, + ) val status = if (fieldUiModel.focused) { @@ -72,6 +90,23 @@ fun provideParameterSelectorItem( }, status = status, onExpand = { + if (SimprintsIntentUtils.isIdentifyCallout(fieldUiModel.customIntent)) { + if (!simprintsCustomIntentFormPresenter.hasPendingValue) { + simprintsCustomIntentFormPresenter.prepareLaunch() + simprintsCustomIntentFormPresenter + .createLaunchIntent() + ?.let { launchIntent -> + onSimprintsBiometricIdentificationLaunch( + fieldUiModel.uid, + fieldUiModel.valueType, + fieldUiModel.customIntent?.customIntentResponse?.let(Gson()::toJson), + SimprintsIntentUtils.isIdentifyCallout(fieldUiModel.customIntent), + launchIntent, + ) + } + } + return@ParameterSelectorItemModel + } performOnExpandActions(fieldUiModel, callback) }, ) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/BackdropManager.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/BackdropManager.kt index 0b09a69bdfe..e197e35b5ea 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/BackdropManager.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/BackdropManager.kt @@ -15,10 +15,13 @@ object BackdropManager { backdropLayout: ConstraintLayout, endID: Int, margin: Int, + withAnimation: Boolean = true, ) { - val transition: Transition = ChangeBounds() - transition.duration = CHANGE_BOUND_DURATION - TransitionManager.beginDelayedTransition(backdropLayout, transition) + if (withAnimation) { + val transition: Transition = ChangeBounds() + transition.duration = CHANGE_BOUND_DURATION + TransitionManager.beginDelayedTransition(backdropLayout, transition) + } val initSet = ConstraintSet() initSet.clone(backdropLayout) @@ -50,7 +53,8 @@ object BackdropManager { backdropLayout: ConstraintLayout, endID: Int, margin: Int, + withAnimation: Boolean = true, ) { - if (condition) changeBounds(isNavigationBarVisible, backdropLayout, endID, margin) + if (condition) changeBounds(isNavigationBarVisible, backdropLayout, endID, margin, withAnimation) } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt index 4152f84775e..82e7483cadc 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt @@ -85,7 +85,7 @@ class SearchScreenConfigurator( changeBounds(false, R.id.filterRecyclerLayout, 16.dp) } - fun closeBackdrop() { + fun closeBackdrop(withAnimation: Boolean = false) { if (isPortrait()) { binding.programSpinner.visibility = View.VISIBLE binding.title.visibility = View.GONE @@ -93,7 +93,7 @@ class SearchScreenConfigurator( binding.filterRecyclerLayout.visibility = View.GONE binding.searchContainer.visibility = View.GONE filterIsOpenCallback(false) - changeBounds(true, R.id.backdropGuideTop, 0) + changeBounds(true, R.id.backdropGuideTop, 0, withAnimation) } private fun openSearch() { @@ -111,6 +111,7 @@ class SearchScreenConfigurator( isNavigationBarVisible: Boolean, endID: Int, margin: Int, + withAnimation: Boolean = true, ) { changeBoundsIf( isPortrait(), @@ -118,6 +119,7 @@ class SearchScreenConfigurator( binding.backdropLayout, endID, margin, + withAnimation, ) } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt index 2ce64ad1ffd..56eda9bcf90 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt @@ -188,6 +188,7 @@ fun AddNewButton( fun SearchButtonWithQuery( modifier: Modifier = Modifier, queryData: Map = emptyMap(), + isSimprintsBiometricSearch: Boolean = false, onClick: () -> Unit, onClearSearchQuery: () -> Unit, ) { @@ -207,15 +208,21 @@ fun SearchButtonWithQuery( .padding(end = 48.dp) .clip(RoundedCornerShape(50)) .background(Color.Unspecified) - .clickable( - onClick = onClick, - interactionSource = remember { MutableInteractionSource() }, - indication = - ripple( - true, - color = SurfaceColor.Primary, - ), - ), + .run { + if (isSimprintsBiometricSearch) { + this + } else { + clickable( + onClick = onClick, + interactionSource = remember { MutableInteractionSource() }, + indication = + ripple( + true, + color = SurfaceColor.Primary, + ), + ) + } + }, ) } } @@ -245,6 +252,9 @@ fun FullSearchButtonAndWorkingList( onEnrollClick: () -> Unit = {}, onCloseFilters: () -> Unit = {}, onClearSearchQuery: () -> Unit = {}, + isSimprintsBiometricSearch: Boolean = false, + isSimprintsPossibleDuplicatesSearch: Boolean = false, + onSimprintsBiometricSearchFallbackClick: () -> Unit = {}, workingListViewModel: WorkingListViewModel? = null, ) { Column(modifier = modifier) { @@ -270,6 +280,8 @@ fun FullSearchButtonAndWorkingList( SearchButtonWithQuery( modifier = Modifier.fillMaxWidth(), queryData = queryData, + isSimprintsBiometricSearch = + isSimprintsBiometricSearch || isSimprintsPossibleDuplicatesSearch, onClick = onSearchClick, onClearSearchQuery = onClearSearchQuery, ) @@ -309,6 +321,18 @@ fun FullSearchButtonAndWorkingList( } } } + + if (isSimprintsBiometricSearch && !isSimprintsPossibleDuplicatesSearch && queryData.isNotEmpty()) { + SimprintsBiometricSearchFallbackButton( + modifier = + Modifier.padding( + top = Spacing.Spacing8, + start = Spacing.Spacing16, + end = Spacing.Spacing16, + ), + onClick = onSimprintsBiometricSearchFallbackClick, + ) + } } Spacer(modifier = Modifier.requiredHeight(Spacing.Spacing16)) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SimprintsSearchTeUiComponents.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SimprintsSearchTeUiComponents.kt new file mode 100644 index 00000000000..9f62422136f --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SimprintsSearchTeUiComponents.kt @@ -0,0 +1,74 @@ +package org.dhis2.usescases.searchTrackEntity.ui + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.dhis2.R +import org.hisp.dhis.mobile.ui.designsystem.component.ExtendedFAB +import org.hisp.dhis.mobile.ui.designsystem.component.FABStyle +import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2TextStyle +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.getTextStyle + +@Composable +fun SimprintsBiometricSearchFallbackButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + OutlinedButton( + modifier = + modifier + .fillMaxWidth() + .requiredHeight(56.dp), + onClick = onClick, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = SurfaceColor.Primary, + ), + border = BorderStroke(1.dp, SurfaceColor.Primary), + shape = RoundedCornerShape(Spacing.Spacing16), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = "", + tint = SurfaceColor.Primary, + ) + + Spacer(modifier = Modifier.requiredWidth(Spacing.Spacing8)) + + Text( + text = stringResource(R.string.simprints_biometric_search_fallback_action), + style = getTextStyle(style = DHIS2TextStyle.LABEL_LARGE), + ) + } +} + +@ExperimentalAnimationApi +@Composable +fun SimprintsNoneOfTheAboveButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + ExtendedFAB( + modifier = modifier, + onClick = onClick, + text = stringResource(R.string.simprints_none_of_the_above), + icon = {}, + style = FABStyle.SECONDARY, + ) +} + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a39fdfb0e6..d06b6357755 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,11 @@ Search Search / Add new %s Search for %s + Biometric search + Possible duplicates + No biometric match?\nSearch by name instead + No biometric search matches. Try searching by name, etc. + None of the above Add new %s Enter text Enter long text diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt index f644caf27af..7167c919bf3 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt @@ -28,8 +28,9 @@ class SimprintsEnrollmentViewModelTest { private val resultMapper: SimprintsCustomIntentResultMapper = mock() @Test - fun `onFinishRequested should return null when enrollment is not new`() = + fun `onFinishRequested should return null when there is no pending enrollment session`() = runTest { + whenever(sessionRepository.pendingEnrollmentSessionId()) doReturn null val viewModel = SimprintsEnrollmentViewModel( simprintsD2Repository = simprintsD2Repository, @@ -40,7 +41,6 @@ class SimprintsEnrollmentViewModelTest { val launchIntent = viewModel.onFinishRequested( - isNewEnrollment = false, enrollmentUid = "enrollment-uid", ) @@ -49,9 +49,9 @@ class SimprintsEnrollmentViewModelTest { } @Test - fun `onFinishRequested should return null when there is no pending enrollment session`() = + fun `onAutoEnrollLastRequested should return null when there is no session`() = runTest { - whenever(sessionRepository.pendingEnrollmentSessionId()) doReturn null + whenever(sessionRepository.get()) doReturn null val viewModel = SimprintsEnrollmentViewModel( simprintsD2Repository = simprintsD2Repository, @@ -61,8 +61,7 @@ class SimprintsEnrollmentViewModelTest { ) val launchIntent = - viewModel.onFinishRequested( - isNewEnrollment = true, + viewModel.onAutoEnrollLastRequested( enrollmentUid = "enrollment-uid", ) @@ -110,7 +109,6 @@ class SimprintsEnrollmentViewModelTest { val preparedIntent = viewModel.onFinishRequested( - isNewEnrollment = true, enrollmentUid = "enrollment-uid", ) val result = @@ -118,9 +116,125 @@ class SimprintsEnrollmentViewModelTest { resultCode = RESULT_OK, data = resultIntent, teiUid = "tei-uid", + enrollmentUid = "enrollment-uid", + ) + + assertSame(launchIntent, preparedIntent) + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH, result) + verify(simprintsD2Repository).saveTrackedEntityAttributeValue( + teiUid = "tei-uid", + attributeUid = "attribute-uid", + value = "subject-guid", + ) + verify(sessionRepository).clear() + } + + @Test + fun `onAutoEnrollLastRequested should save mapped value clear session and continue finish`() = + runTest { + val launchIntent: Intent = mock() + val resultIntent: Intent = mock() + val responseData = + listOf( + CustomIntentResponseDataModel( + name = "subjectId", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ) + val pendingAction = + SimprintsResolvePendingEnrollmentActionUseCase.PendingEnrollmentAction( + fieldUid = "attribute-uid", + callout = + SimprintsIntentUtils.PreparedCallout( + launchIntent = launchIntent, + responseData = responseData, + ), + ) + whenever(sessionRepository.get()) doReturn "session-id" + whenever( + resolvePendingEnrollmentAction.invoke( + "enrollment-uid", + "session-id", + ), + ) doReturn pendingAction + whenever(resultMapper.map(responseData, resultIntent)) doReturn "subject-guid" + val viewModel = + SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ) + + val preparedIntent = + viewModel.onAutoEnrollLastRequested( + enrollmentUid = "enrollment-uid", + ) + val result = + viewModel.onRegisterLastResult( + resultCode = RESULT_OK, + data = resultIntent, + teiUid = "tei-uid", + enrollmentUid = "enrollment-uid", ) assertSame(launchIntent, preparedIntent) + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH, result) + verify(sessionRepository).markPendingEnrollmentFromPossibleDuplicates() + verify(simprintsD2Repository).saveTrackedEntityAttributeValue( + teiUid = "tei-uid", + attributeUid = "attribute-uid", + value = "subject-guid", + ) + verify(sessionRepository).clear() + } + + @Test + fun `onRegisterLastResult should recover auto enroll last after recreation and save mapped value`() = + runTest { + val resultIntent: Intent = mock() + val responseData = + listOf( + CustomIntentResponseDataModel( + name = "subjectId", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ) + val pendingAction = + SimprintsResolvePendingEnrollmentActionUseCase.PendingEnrollmentAction( + fieldUid = "attribute-uid", + callout = + SimprintsIntentUtils.PreparedCallout( + launchIntent = mock(), + responseData = responseData, + ), + ) + whenever(sessionRepository.pendingEnrollmentSessionId()) doReturn "session-id" + whenever( + resolvePendingEnrollmentAction.invoke( + "enrollment-uid", + "session-id", + ), + ) doReturn pendingAction + whenever(resultMapper.map(responseData, resultIntent)) doReturn "subject-guid" + val viewModel = + SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ) + + val result = + viewModel.onRegisterLastResult( + resultCode = RESULT_OK, + data = resultIntent, + teiUid = "tei-uid", + enrollmentUid = "enrollment-uid", + ) + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH, result) verify(simprintsD2Repository).saveTrackedEntityAttributeValue( teiUid = "tei-uid", @@ -168,7 +282,6 @@ class SimprintsEnrollmentViewModelTest { ) viewModel.onFinishRequested( - isNewEnrollment = true, enrollmentUid = "enrollment-uid", ) val result = @@ -176,6 +289,7 @@ class SimprintsEnrollmentViewModelTest { resultCode = RESULT_OK, data = resultIntent, teiUid = "tei-uid", + enrollmentUid = "enrollment-uid", ) assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.ERROR, result) @@ -184,6 +298,7 @@ class SimprintsEnrollmentViewModelTest { any(), any(), ) + verify(sessionRepository).clearPendingEnrollment() verify(sessionRepository, never()).clear() } @@ -215,7 +330,6 @@ class SimprintsEnrollmentViewModelTest { ) viewModel.onFinishRequested( - isNewEnrollment = true, enrollmentUid = "enrollment-uid", ) val result = @@ -223,10 +337,13 @@ class SimprintsEnrollmentViewModelTest { resultCode = RESULT_OK, data = mock(), teiUid = null, + enrollmentUid = "enrollment-uid", ) assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.ERROR, result) verify(resultMapper, never()).map(any(), any()) + verify(sessionRepository).clearPendingEnrollment() + verify(sessionRepository, never()).clear() } @Test @@ -245,21 +362,30 @@ class SimprintsEnrollmentViewModelTest { resultCode = RESULT_OK, data = mock(), teiUid = "tei-uid", + enrollmentUid = null, ) assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.NONE, result) } @Test - fun `onRegisterLastLaunchFailed should discard pending action`() = + fun `onRegisterLastResult should recover lost pending action after recreation and clear pending enrollment on error`() = runTest { + val responseData = + listOf( + CustomIntentResponseDataModel( + name = "subjectId", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ) val pendingAction = SimprintsResolvePendingEnrollmentActionUseCase.PendingEnrollmentAction( fieldUid = "attribute-uid", callout = SimprintsIntentUtils.PreparedCallout( launchIntent = mock(), - responseData = emptyList(), + responseData = responseData, ), ) whenever(sessionRepository.pendingEnrollmentSessionId()) doReturn "session-id" @@ -277,18 +403,55 @@ class SimprintsEnrollmentViewModelTest { resultMapper = resultMapper, ) - viewModel.onFinishRequested( - isNewEnrollment = true, - enrollmentUid = "enrollment-uid", - ) - viewModel.onRegisterLastLaunchFailed() val result = viewModel.onRegisterLastResult( - resultCode = RESULT_OK, + resultCode = 0, data = mock(), teiUid = "tei-uid", + enrollmentUid = "enrollment-uid", ) - assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.NONE, result) + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.ERROR, result) + verify(sessionRepository).clearPendingEnrollment() + verify(simprintsD2Repository, never()).saveTrackedEntityAttributeValue( + any(), + any(), + any(), + ) + } + + @Test + fun `onRegisterLastLaunchFailed should clear pending enrollment`() = + runTest { + val pendingAction = + SimprintsResolvePendingEnrollmentActionUseCase.PendingEnrollmentAction( + fieldUid = "attribute-uid", + callout = + SimprintsIntentUtils.PreparedCallout( + launchIntent = mock(), + responseData = emptyList(), + ), + ) + whenever(sessionRepository.pendingEnrollmentSessionId()) doReturn "session-id" + whenever( + resolvePendingEnrollmentAction.invoke( + "enrollment-uid", + "session-id", + ), + ) doReturn pendingAction + val viewModel = + SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ) + + viewModel.onFinishRequested( + enrollmentUid = "enrollment-uid", + ) + viewModel.onRegisterLastLaunchFailed() + + verify(sessionRepository).clearPendingEnrollment() } } diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCaseTest.kt new file mode 100644 index 00000000000..d9d5302e140 --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCaseTest.kt @@ -0,0 +1,124 @@ +package org.dhis2.simprints + +import androidx.paging.testing.asSnapshot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase +import org.dhis2.data.search.SearchParametersModel +import org.dhis2.form.model.FieldUiModelImpl +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt +import org.dhis2.usescases.searchTrackEntity.SearchTeiModel +import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class SimprintsLoadBiometricSearchResultsUseCaseTest { + private val searchRepository: SearchRepository = mock() + private val searchRepositoryKt: SearchRepositoryKt = mock() + private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase = + mock() + + private val useCase = + SimprintsLoadBiometricSearchResultsUseCase( + searchRepository = searchRepository, + searchRepositoryKt = searchRepositoryKt, + orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, + ) + + @Test + fun `invoke should return ordered biometric search results when available`() = + runTest { + val firstItem = mock() + val secondItem = mock() + val firstModel = searchTeiModel("first") + val secondModel = searchTeiModel("second") + whenever( + orderSearchResultsByIdentifyResponse( + any(), + any(), + any List>(), + ), + ) doReturn listOf(secondItem, firstItem) + whenever(searchRepository.transform(secondItem, null, false, null)) doReturn secondModel + whenever(searchRepository.transform(firstItem, null, false, null)) doReturn firstModel + + val result = + useCase( + searchItems = listOf(simprintsBiometricSearchField()), + searchParametersModel = + SearchParametersModel( + selectedProgram = null, + queryData = mutableMapOf("biometric" to listOf("guid-1", "guid-2")), + ), + isOnline = true, + offlineOnly = false, + sortingItem = null, + ) + + assertEquals(listOf(secondModel, firstModel), result?.asSnapshot()) + verify(searchRepository).transform(secondItem, null, false, null) + verify(searchRepository).transform(firstItem, null, false, null) + } + + @Test + fun `invoke should return null when biometric ordering is not available`() = + runTest { + whenever( + orderSearchResultsByIdentifyResponse( + any(), + any(), + any List>(), + ), + ) doReturn null + + val result = + useCase( + searchItems = listOf(simprintsBiometricSearchField()), + searchParametersModel = + SearchParametersModel( + selectedProgram = null, + queryData = mutableMapOf("biometric" to listOf("guid-1")), + ), + isOnline = true, + offlineOnly = false, + sortingItem = null, + ) + + assertNull(result) + } + + private fun simprintsBiometricSearchField() = + FieldUiModelImpl( + uid = "biometric", + label = "Biometric", + value = "guid-1,guid-2", + displayName = "guid-1,guid-2", + autocompleteList = emptyList(), + optionSetConfiguration = null, + valueType = ValueType.TEXT, + customIntent = + CustomIntentModel( + uid = "identify-intent", + name = "Identify", + packageName = "com.simprints.id.IDENTIFY", + customIntentRequest = emptyList(), + customIntentResponse = emptyList(), + ), + ) + + private fun searchTeiModel(header: String) = + SearchTeiModel().apply { + setHeader(header) + } +} diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest.kt new file mode 100644 index 00000000000..ea227fdfb08 --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest.kt @@ -0,0 +1,112 @@ +package org.dhis2.simprints + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.dhis2.data.search.SearchParametersModel +import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt +import org.dhis2.usescases.searchTrackEntity.SearchTeiModel +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest { + private val searchRepository: SearchRepository = mock() + private val searchRepositoryKt: SearchRepositoryKt = mock() + + private val useCase = + SimprintsLoadPossibleDuplicatesSearchResultsUseCase( + searchRepository = searchRepository, + searchRepositoryKt = searchRepositoryKt, + ) + + @Test + fun `invoke should return null when there is no query data`() = + runTest { + val result = + useCase( + searchParametersModel = SearchParametersModel(selectedProgram = null, queryData = null), + isOnline = true, + offlineOnly = false, + sortingItem = null, + ) + + assertNull(result) + } + + @Test + fun `invoke should return null when query contains only blank values`() = + runTest { + val result = + useCase( + searchParametersModel = + SearchParametersModel( + selectedProgram = null, + queryData = mutableMapOf("biometric" to listOf(" ", "")), + ), + isOnline = true, + offlineOnly = false, + sortingItem = null, + ) + + assertNull(result) + } + + @Test + fun `invoke should return transformed possible duplicates results in order`() = + runTest { + val item1 = + mock { + on { uid() } doReturn "tei-1" + } + val item2 = + mock { + on { uid() } doReturn "tei-2" + } + + whenever(searchRepositoryKt.searchTrackedEntitiesImmediate(any(), any())).thenAnswer { + val params = it.arguments[0] as SearchParametersModel + when (params.queryData?.get("biometric")?.firstOrNull()) { + "guid-1" -> listOf(item1, item2) + "guid-2" -> listOf(item1) + else -> emptyList() + } + } + + val model1 = searchTeiModel("model-1") + val model2 = searchTeiModel("model-2") + whenever(searchRepository.transform(item1, null, false, null)) doReturn model1 + whenever(searchRepository.transform(item2, null, false, null)) doReturn model2 + + val result = + useCase( + searchParametersModel = + SearchParametersModel( + selectedProgram = null, + queryData = mutableMapOf("biometric" to listOf("guid-1", "guid-2")), + ), + isOnline = true, + offlineOnly = false, + sortingItem = null, + ) + + assertEquals(listOf(model1, model2), result) + verify(searchRepositoryKt, times(2)).searchTrackedEntitiesImmediate(any(), any()) + verify(searchRepository).transform(item1, null, false, null) + verify(searchRepository).transform(item2, null, false, null) + } + + private fun searchTeiModel(header: String) = + SearchTeiModel().apply { + setHeader(header) + } +} + diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt new file mode 100644 index 00000000000..eddc1dc31bb --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt @@ -0,0 +1,190 @@ +package org.dhis2.simprints + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.filters.FilterManager +import org.dhis2.commons.network.NetworkUtils +import org.dhis2.data.search.SearchParametersModel +import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt +import org.dhis2.usescases.searchTrackEntity.SearchTeiModel +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.program.Program +import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class SimprintsResolveSingleBiometricSearchNavigationUseCaseTest { + private val searchRepository: SearchRepository = mock() + private val searchRepositoryKt: SearchRepositoryKt = mock() + private val networkUtils: NetworkUtils = mock() + private val filterManager: FilterManager = mock() + + @Test + fun `invoke should return matched enrollment navigation when a single biometric result is found`() = + runTest { + val program = Program.builder().uid("initialProgramUid").build() + val trackedEntity: TrackedEntitySearchItem = mock() + val searchTeiModel = + searchTeiModel( + teiUid = "teiUid", + enrollmentUid = "enrollmentUid", + enrollmentProgramUid = "matchedProgramUid", + ) + whenever(searchRepository.getProgram("initialProgramUid")) doReturn program + whenever(networkUtils.isOnline()) doReturn true + whenever(filterManager.stateFilters) doReturn emptyList() + whenever( + searchRepositoryKt.searchTrackedEntitiesImmediate( + SearchParametersModel( + selectedProgram = program, + queryData = mutableMapOf("biometric" to listOf("guid-1")), + ), + true, + ), + ) doReturn listOf(trackedEntity) + whenever(searchRepository.transform(trackedEntity, program, false, null)) doReturn + searchTeiModel + + val result = + useCase(StandardTestDispatcher(testScheduler))( + initialProgramUid = "initialProgramUid", + queryData = mapOf("biometric" to listOf("guid-1")), + value = "guid-1", + ) + + assertEquals( + SimprintsResolveSingleBiometricSearchNavigationUseCase.NavigationTarget( + teiUid = "teiUid", + programUid = "matchedProgramUid", + enrollmentUid = "enrollmentUid", + isOnline = true, + ), + result, + ) + } + + @Test + fun `invoke should fall back to the initial program when the matched result has no selected enrollment`() = + runTest { + val program = Program.builder().uid("initialProgramUid").build() + val trackedEntity: TrackedEntitySearchItem = mock() + whenever(searchRepository.getProgram("initialProgramUid")) doReturn program + whenever(networkUtils.isOnline()) doReturn false + whenever(filterManager.stateFilters) doReturn emptyList() + whenever( + searchRepositoryKt.searchTrackedEntitiesImmediate( + SearchParametersModel( + selectedProgram = program, + queryData = mutableMapOf("biometric" to listOf("guid-1")), + ), + false, + ), + ) doReturn listOf(trackedEntity) + whenever(searchRepository.transform(trackedEntity, program, true, null)) doReturn + searchTeiModel(teiUid = "teiUid") + + val result = + useCase(StandardTestDispatcher(testScheduler))( + initialProgramUid = "initialProgramUid", + queryData = mapOf("biometric" to listOf("guid-1")), + value = "guid-1", + ) + + assertEquals( + SimprintsResolveSingleBiometricSearchNavigationUseCase.NavigationTarget( + teiUid = "teiUid", + programUid = "initialProgramUid", + enrollmentUid = null, + isOnline = true, + ), + result, + ) + } + + @Test + fun `invoke should return null when biometric value does not resolve to exactly one identifier`() = + runTest { + val result = + useCase(StandardTestDispatcher(testScheduler))( + initialProgramUid = "initialProgramUid", + queryData = mapOf("biometric" to listOf("guid-1", "guid-2")), + value = "guid-1,guid-2", + ) + + assertNull(result) + verify(searchRepositoryKt, never()).searchTrackedEntitiesImmediate(any(), any()) + } + + @Test + fun `invoke should return null when search resolves multiple tracked entities`() = + runTest { + val program = Program.builder().uid("initialProgramUid").build() + whenever(searchRepository.getProgram("initialProgramUid")) doReturn program + whenever(networkUtils.isOnline()) doReturn true + whenever(filterManager.stateFilters) doReturn emptyList() + whenever( + searchRepositoryKt.searchTrackedEntitiesImmediate( + SearchParametersModel( + selectedProgram = program, + queryData = mutableMapOf("biometric" to listOf("guid-1")), + ), + true, + ), + ) doReturn listOf(mock(), mock()) + + val result = + useCase(StandardTestDispatcher(testScheduler))( + initialProgramUid = "initialProgramUid", + queryData = mapOf("biometric" to listOf("guid-1")), + value = "guid-1", + ) + + assertNull(result) + verify(searchRepository, never()).transform(any(), anyOrNull(), any(), anyOrNull()) + } + + private fun useCase(ioDispatcher: CoroutineDispatcher) = + SimprintsResolveSingleBiometricSearchNavigationUseCase( + searchRepository = searchRepository, + searchRepositoryKt = searchRepositoryKt, + networkUtils = networkUtils, + filterManager = filterManager, + ioDispatcher = ioDispatcher, + ) + + private fun searchTeiModel( + teiUid: String, + enrollmentUid: String? = null, + enrollmentProgramUid: String? = null, + ) = SearchTeiModel().apply { + tei = + TrackedEntityInstance + .builder() + .uid(teiUid) + .trackedEntityType("teiType") + .organisationUnit("orgUnit") + .build() + enrollmentUid?.let { uid -> + setCurrentEnrollment( + Enrollment + .builder() + .uid(uid) + .program(enrollmentProgramUid) + .build(), + ) + } + } +} diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt index 39e372dcdd7..d0f06674cec 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt @@ -2,38 +2,48 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test import kotlinx.coroutines.test.runTest import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase import org.dhis2.commons.simprints.utils.SimprintsIntentUtils -import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +import org.dhis2.form.model.FieldUiModelImpl import org.dhis2.mobile.commons.model.CustomIntentModel import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType +import org.hisp.dhis.android.core.common.ValueType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class SimprintsSearchViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase = mock() private val sessionRepository: SimprintsSessionRepository = mock() + private val resolveSingleBiometricSearchNavigation: SimprintsResolveSingleBiometricSearchNavigationUseCase = + mock() @Test fun `onDashboardRequested should launch confirm identity once and clear pending session`() = runTest { val launchIntent: Intent = mock() whenever(sessionRepository.get()) doReturn "session-id" - whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any())) doReturn + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any(), any())) doReturn SimprintsIntentUtils.PreparedCallout( launchIntent = launchIntent, responseData = emptyList(), @@ -42,11 +52,12 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) val action = viewModel.onDashboardRequested( - searchFields = listOf(identifyField(value = "guid-1")), + searchItems = listOf(identifyField(value = "guid-1")), teiUid = "tei-uid", programUid = "program-uid", enrollmentUid = "enrollment-uid", @@ -66,11 +77,41 @@ class SimprintsSearchViewModelTest { assertNull(viewModel.onConfirmIdentityResult(RESULT_OK)) } + @Test + fun `onDashboardRequested should keep session when requested`() = + runTest { + val launchIntent: Intent = mock() + whenever(sessionRepository.get()) doReturn "session-id" + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any(), any())) doReturn + SimprintsIntentUtils.PreparedCallout( + launchIntent = launchIntent, + responseData = emptyList(), + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + + val action = + viewModel.onDashboardRequested( + searchItems = listOf(identifyField(value = "guid-1")), + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + keepSession = true, + ) + + assertTrue(action is SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity) + verify(sessionRepository, never()).clear() + } + @Test fun `onDashboardRequested should open dashboard directly after confirm identity already cleared session`() = runTest { whenever(sessionRepository.get()).thenReturn("session-id").thenReturn(null) - whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any())) doReturn + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any(), any())) doReturn SimprintsIntentUtils.PreparedCallout( launchIntent = mock(), responseData = emptyList(), @@ -79,18 +120,19 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) val firstAction = viewModel.onDashboardRequested( - searchFields = listOf(identifyField(value = "guid-1")), + searchItems = listOf(identifyField(value = "guid-1")), teiUid = "tei-uid", programUid = "program-uid", enrollmentUid = "enrollment-uid", ) val secondAction = viewModel.onDashboardRequested( - searchFields = listOf(identifyField(value = "guid-1")), + searchItems = listOf(identifyField(value = "guid-1")), teiUid = "tei-uid", programUid = "program-uid", enrollmentUid = "enrollment-uid", @@ -105,16 +147,17 @@ class SimprintsSearchViewModelTest { fun `onDashboardRequested should open dashboard when confirm identity is not available`() = runTest { whenever(sessionRepository.get()) doReturn "session-id" - whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any())) doReturn null + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any(), any())) doReturn null val viewModel = SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) val action = viewModel.onDashboardRequested( - searchFields = listOf(identifyField(value = "guid-1")), + searchItems = listOf(identifyField(value = "guid-1")), teiUid = "tei-uid", programUid = "program-uid", enrollmentUid = "enrollment-uid", @@ -124,6 +167,56 @@ class SimprintsSearchViewModelTest { verify(sessionRepository, never()).clear() } + @Test + fun `onDashboardRequested should confirm identity from sequential fallback and clear last biometrics option`() = + runTest { + val launchIntent: Intent = mock() + whenever(sessionRepository.hasPendingSession()) doReturn true + whenever(sessionRepository.get()) doReturn "session-id" + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any(), any())) doReturn + SimprintsIntentUtils.PreparedCallout( + launchIntent = launchIntent, + responseData = emptyList(), + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + val queryData = + mutableMapOf?>( + "biometric" to listOf("guid-1"), + "name" to listOf("Name"), + ) + val searchItems = + viewModel.clearSimprintsBiometricQueryData( + searchItems = + listOf( + identifyField(value = "guid-1"), + textField(uid = "name", value = "Name"), + ), + queryData = queryData, + ).orEmpty() + + val action = + viewModel.onDashboardRequested( + searchItems = searchItems, + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + ) + + assertTrue(action is SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity) + assertSame( + launchIntent, + (action as SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity).intent, + ) + assertFalse(viewModel.shouldUseLastBiometricsLabel(searchItems)) + verify(resolveConfirmIdentityCallout).invoke(eq("tei-uid"), any(), eq("session-id"), eq(true)) + verify(sessionRepository).clear() + } + @Test fun `prepareEnrollmentQueryData should mark pending enrollment and strip biometric fields`() { whenever(sessionRepository.hasPendingSession()) doReturn true @@ -131,23 +224,24 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) val filteredQueryData = viewModel.prepareEnrollmentQueryData( - searchFields = + searchItems = listOf( identifyField(value = "guid-1"), - textField(uid = "name", value = "Alice"), + textField(uid = "name", value = "Name"), ), queryData = mapOf( "biometric" to listOf("guid-1"), - "name" to listOf("Alice"), + "name" to listOf("Name"), ), ) - assertEquals(hashMapOf("name" to listOf("Alice")), filteredQueryData) + assertEquals(hashMapOf("name" to listOf("Name")), filteredQueryData) verify(sessionRepository).markPendingEnrollment() } @@ -158,16 +252,82 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + + val filteredQueryData = + viewModel.prepareEnrollmentQueryData( + searchItems = listOf(textField(uid = "name", value = "Name")), + queryData = mapOf("name" to listOf("Name")), + ) + + assertEquals(hashMapOf("name" to listOf("Name")), filteredQueryData) + verify(sessionRepository, never()).markPendingEnrollment() + } + + @Test + fun `prepareEnrollmentQueryData should mark pending enrollment after biometric fallback search`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + val queryData = + mutableMapOf?>( + "biometric" to listOf("guid-1"), + "name" to listOf("Name"), + ) + val searchItems = + viewModel.clearSimprintsBiometricQueryData( + searchItems = + listOf( + identifyField(value = "guid-1"), + textField(uid = "name", value = "Name"), + ), + queryData = queryData, + ).orEmpty() + + val filteredQueryData = + viewModel.prepareEnrollmentQueryData( + searchItems = searchItems, + queryData = queryData, + ) + + assertEquals(hashMapOf("name" to listOf("Name")), filteredQueryData) + verify(sessionRepository).markPendingEnrollment() + verify(sessionRepository, never()).clear() + } + + @Test + fun `prepareEnrollmentQueryData should wait for non biometric query after biometric fallback search`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) + val queryData = + mutableMapOf?>( + "biometric" to listOf("guid-1"), + ) + val searchItems = + viewModel.clearSimprintsBiometricQueryData( + searchItems = listOf(identifyField(value = "guid-1")), + queryData = queryData, + ).orEmpty() val filteredQueryData = viewModel.prepareEnrollmentQueryData( - searchFields = listOf(textField(uid = "name", value = "Alice")), - queryData = mapOf("name" to listOf("Alice")), + searchItems = searchItems, + queryData = queryData, ) - assertEquals(hashMapOf("name" to listOf("Alice")), filteredQueryData) + assertEquals(hashMapOf>(), filteredQueryData) verify(sessionRepository, never()).markPendingEnrollment() + verify(sessionRepository, never()).clear() } @Test @@ -177,15 +337,45 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) viewModel.clearPendingSessionIfNeeded( - searchFields = listOf(textField(uid = "name", value = "Name")), + searchItems = listOf(textField(uid = "name", value = "Name")), ) verify(sessionRepository).clear() } + @Test + fun `clearPendingSessionIfNeeded should keep session after biometric fallback search`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + val queryData = + mutableMapOf?>( + "biometric" to listOf("guid-1"), + "name" to listOf("Name"), + ) + val searchItems = + viewModel.clearSimprintsBiometricQueryData( + searchItems = + listOf( + identifyField(value = "guid-1"), + textField(uid = "name", value = "Name"), + ), + queryData = queryData, + ).orEmpty() + + viewModel.clearPendingSessionIfNeeded(searchItems = searchItems) + + verify(sessionRepository, never()).clear() + } + @Test fun `shouldUseLastBiometricsLabel should return false for non biometric search`() { whenever(sessionRepository.hasPendingSession()) doReturn true @@ -193,11 +383,12 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) val shouldUseLastBiometricsLabel = viewModel.shouldUseLastBiometricsLabel( - searchFields = listOf(textField(uid = "name", value = "Alice")), + searchItems = listOf(textField(uid = "name", value = "Name")), ) assertFalse(shouldUseLastBiometricsLabel) @@ -211,11 +402,12 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) val shouldUseLastBiometricsLabel = viewModel.shouldUseLastBiometricsLabel( - searchFields = listOf(identifyField(value = "guid-1")), + searchItems = listOf(identifyField(value = "guid-1")), ) assertTrue(shouldUseLastBiometricsLabel) @@ -225,7 +417,7 @@ class SimprintsSearchViewModelTest { fun `onConfirmIdentityResult should clear pending navigation when cancelled`() = runTest { whenever(sessionRepository.get()) doReturn "session-id" - whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any())) doReturn + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any(), any())) doReturn SimprintsIntentUtils.PreparedCallout( launchIntent = mock(), responseData = emptyList(), @@ -234,10 +426,11 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) viewModel.onDashboardRequested( - searchFields = listOf(identifyField(value = "guid-1")), + searchItems = listOf(identifyField(value = "guid-1")), teiUid = "tei-uid", programUid = "program-uid", enrollmentUid = "enrollment-uid", @@ -247,19 +440,431 @@ class SimprintsSearchViewModelTest { assertNull(viewModel.onConfirmIdentityResult(RESULT_OK)) } + @Test + fun `onConfirmIdentityLaunchFailed should clear pending dashboard navigation`() = + runTest { + whenever(sessionRepository.get()) doReturn "session-id" + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any(), any())) doReturn + SimprintsIntentUtils.PreparedCallout( + launchIntent = mock(), + responseData = emptyList(), + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + + viewModel.onDashboardRequested( + searchItems = listOf(identifyField(value = "guid-1")), + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + ) + viewModel.onConfirmIdentityLaunchFailed() + + assertNull(viewModel.onConfirmIdentityResult(RESULT_OK)) + } + + @Test + fun `refreshSimprintsUiState should expose Simprints biometric search state`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + + viewModel.refreshSimprintsUiState( + searchItems = listOf(identifyField(value = "guid-1")), + ) + + assertTrue(viewModel.isSimprintsBiometricSearch.value == true) + assertTrue(viewModel.isSimprintsUseLastBiometricsLabel.value == true) + } + + @Test + fun `onSimprintsParameterSaved should emit Simprints biometric search navigation when biometric values are saved`() = + runTest { + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + + viewModel.simprintsBiometricSearchNavigation.test { + val navigation = + viewModel.onSimprintsParameterSaved( + uid = "biometric", + value = "guid-1,guid-2", + searchItems = listOf(identifyField(value = "guid-1,guid-2")), + initialProgramUid = "program-uid", + queryData = mapOf("biometric" to listOf("guid-1", "guid-2")), + ) + + assertNull(navigation) + awaitItem() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onSimprintsParameterSaved should ignore non Simprints fields`() = + runTest { + whenever( + resolveSingleBiometricSearchNavigation( + initialProgramUid = any(), + queryData = any(), + value = any(), + ), + ) doReturn + SimprintsResolveSingleBiometricSearchNavigationUseCase.NavigationTarget( + teiUid = "teiUid", + programUid = "programUid", + enrollmentUid = "enrollmentUid", + isOnline = false, + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + + viewModel.simprintsBiometricSearchNavigation.test { + val navigation = + viewModel.onSimprintsParameterSaved( + uid = "name", + value = "Name", + searchItems = listOf(textField(uid = "name", value = "Name")), + initialProgramUid = "program-uid", + queryData = mapOf("name" to listOf("Name")), + ) + + assertNull(navigation) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onSimprintsBiometricIdentificationResult should clear pending direct navigation when value becomes blank`() = + runTest { + whenever( + resolveSingleBiometricSearchNavigation( + initialProgramUid = any(), + queryData = any(), + value = any(), + ), + ) doReturn + SimprintsResolveSingleBiometricSearchNavigationUseCase.NavigationTarget( + teiUid = "teiUid", + programUid = "programUid", + enrollmentUid = "enrollmentUid", + isOnline = false, + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + viewModel.onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "guid-1", + hasAutoOpenEligibleSimprintsIdentification = true, + ) + viewModel.onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "", + hasAutoOpenEligibleSimprintsIdentification = true, + ) + + viewModel.simprintsBiometricSearchNavigation.test { + val navigation = + viewModel.onSimprintsParameterSaved( + uid = "biometric", + value = "guid-1", + searchItems = listOf(identifyField(value = "guid-1")), + initialProgramUid = "program-uid", + queryData = mapOf("biometric" to listOf("guid-1")), + ) + + assertNull(navigation) + awaitItem() + verify(resolveSingleBiometricSearchNavigation, never()).invoke(any(), any(), any()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onSimprintsParameterSaved should open single Simprints MFID biometric search result directly when eligible`() = + runTest { + whenever( + resolveSingleBiometricSearchNavigation( + initialProgramUid = any(), + queryData = any(), + value = any(), + ), + ) doReturn + SimprintsResolveSingleBiometricSearchNavigationUseCase.NavigationTarget( + teiUid = "teiUid", + programUid = "programUid", + enrollmentUid = "enrollmentUid", + isOnline = false, + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + viewModel.onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "guid-1", + hasAutoOpenEligibleSimprintsIdentification = true, + ) + + val navigation = + viewModel.onSimprintsParameterSaved( + uid = "biometric", + value = "guid-1", + searchItems = listOf(identifyField(value = "guid-1")), + initialProgramUid = "program-uid", + queryData = mapOf("biometric" to listOf("guid-1")), + ) + + assertEquals("teiUid", navigation?.teiUid) + assertEquals("programUid", navigation?.programUid) + assertEquals("enrollmentUid", navigation?.enrollmentUid) + verify(sessionRepository).clear() + } + + @Test + fun `onSimprintsParameterSaved should open pending MFID biometric result directly even before search items are restored`() = + runTest { + whenever( + resolveSingleBiometricSearchNavigation( + initialProgramUid = any(), + queryData = any(), + value = any(), + ), + ) doReturn + SimprintsResolveSingleBiometricSearchNavigationUseCase.NavigationTarget( + teiUid = "teiUid", + programUid = "programUid", + enrollmentUid = "enrollmentUid", + isOnline = true, + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + viewModel.onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "guid-1", + hasAutoOpenEligibleSimprintsIdentification = true, + ) + + val navigation = + viewModel.onSimprintsParameterSaved( + uid = "biometric", + value = "guid-1", + searchItems = emptyList(), + initialProgramUid = "program-uid", + queryData = mapOf("biometric" to listOf("guid-1")), + ) + + assertEquals("teiUid", navigation?.teiUid) + assertEquals("programUid", navigation?.programUid) + assertEquals("enrollmentUid", navigation?.enrollmentUid) + assertTrue(navigation?.isOnline == true) + verify(sessionRepository).clear() + } + + @Test + fun `clearSimprintsBiometricQueryData should keep session for sequential fallback`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + val queryData = + mutableMapOf?>( + "biometric" to listOf("guid-1"), + "name" to listOf("Name"), + ) + + val updatedItems = + viewModel.clearSimprintsBiometricQueryData( + searchItems = + listOf( + identifyField(value = "guid-1"), + textField(uid = "name", value = "Name"), + ), + queryData = queryData, + ) + + assertEquals(mapOf("name" to listOf("Name")), queryData) + assertEquals(null, updatedItems?.first()?.value) + assertEquals("Name", updatedItems?.get(1)?.value) + assertTrue( + viewModel.shouldUseLastBiometricsLabel( + searchItems = listOf(textField(uid = "name", value = "Name")), + ), + ) + verify(sessionRepository, never()).clear() + } + + @Test + fun `clearSimprintsBiometricQueryData should do nothing when search has no Simprints identify field`() { + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + val queryData = mutableMapOf?>("name" to listOf("Name")) + + val updatedItems = + viewModel.clearSimprintsBiometricQueryData( + searchItems = listOf(textField(uid = "name", value = "Name")), + queryData = queryData, + ) + + assertNull(updatedItems) + assertEquals(mapOf("name" to listOf("Name")), queryData) + verify(sessionRepository, never()).clear() + } + + @Test + fun `new biometric search should override sequential fallback biometrics`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + val queryData = + mutableMapOf?>( + "biometric" to listOf("guid-1"), + "name" to listOf("Name"), + ) + viewModel.clearSimprintsBiometricQueryData( + searchItems = + listOf( + identifyField(value = "guid-1"), + textField(uid = "name", value = "Name"), + ), + queryData = queryData, + ) + + viewModel.onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "guid-2", + hasAutoOpenEligibleSimprintsIdentification = false, + ) + + assertFalse( + viewModel.shouldUseLastBiometricsLabel( + searchItems = listOf(textField(uid = "name", value = "Name")), + ), + ) + } + + @Test + fun `onSimprintsParameterSaved should request Simprints biometric search when eligible MFID resolution does not open directly`() = + runTest { + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + viewModel.onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "guid-1", + hasAutoOpenEligibleSimprintsIdentification = true, + ) + + viewModel.simprintsBiometricSearchNavigation.test { + val navigation = + viewModel.onSimprintsParameterSaved( + uid = "biometric", + value = "guid-1", + searchItems = listOf(identifyField(value = "guid-1")), + initialProgramUid = "program-uid", + queryData = mapOf("biometric" to listOf("guid-1")), + ) + + assertNull(navigation) + awaitItem() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onSimprintsParameterSaved should request Simprints biometric search when result is not auto open eligible`() = + runTest { + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + ) + viewModel.onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "guid-1", + hasAutoOpenEligibleSimprintsIdentification = false, + ) + + viewModel.simprintsBiometricSearchNavigation.test { + val navigation = + viewModel.onSimprintsParameterSaved( + uid = "biometric", + value = "guid-1", + searchItems = listOf(identifyField(value = "guid-1")), + initialProgramUid = "program-uid", + queryData = mapOf("biometric" to listOf("guid-1")), + ) + + assertNull(navigation) + awaitItem() + cancelAndIgnoreRemainingEvents() + } + } + private fun identifyField(value: String?) = - SimprintsSearchUtils.SearchField( + FieldUiModelImpl( uid = "biometric", + label = "Biometric", value = value, + displayName = value, + autocompleteList = emptyList(), + optionSetConfiguration = null, + valueType = ValueType.TEXT, customIntent = identifyIntent(), ) private fun textField( uid: String, value: String?, - ) = SimprintsSearchUtils.SearchField( + ) = FieldUiModelImpl( uid = uid, + label = uid, value = value, + displayName = value, + autocompleteList = emptyList(), + optionSetConfiguration = null, + valueType = ValueType.TEXT, customIntent = null, ) diff --git a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt index 805db9035fa..4ce9b67fd85 100644 --- a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt @@ -1,6 +1,7 @@ package org.dhis2.usescases.enrollment import android.content.Intent +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.reactivex.Single import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.test.runTest @@ -29,8 +30,11 @@ import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstanceObjectRepository import org.junit.Before +import org.junit.Rule import org.junit.Test import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -38,6 +42,9 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class EnrollmentPresenterImplTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val enrollmentFormRepository: EnrollmentFormRepository = mock() private val programRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() private val teiRepository: TrackedEntityInstanceObjectRepository = mock() @@ -260,19 +267,19 @@ class EnrollmentPresenterImplTest { } @Test - fun `Should delegate finish request to Simprints enrollment view model`() = runTest { - val intent: Intent = mock() - whenever( - simprintsEnrollmentViewModel.onFinishRequested( - isNewEnrollment = true, - enrollmentUid = "enrollmentUid", - ), - ) doReturn intent + fun `Should delegate finish request to Simprints enrollment view model`() = + runTest { + val intent: Intent = mock() + whenever( + simprintsEnrollmentViewModel.onFinishRequested( + enrollmentUid = "enrollmentUid", + ), + ) doReturn intent - val result = presenter.onFinishRequested(true, "enrollmentUid") + val result = presenter.onFinishRequested("enrollmentUid") - assert(result == intent) - } + assert(result == intent) + } @Test fun `Should delegate register last result using current enrollment tei to Simprints enrollment view model`() = @@ -280,18 +287,31 @@ class EnrollmentPresenterImplTest { val enrollment: Enrollment = mock { on { trackedEntityInstance() } doReturn "teiUid" + on { uid() } doReturn "enrollmentUid" } whenever(enrollmentRepository.blockingGet()) doReturn enrollment whenever( simprintsEnrollmentViewModel.onRegisterLastResult( - resultCode = 1, - data = null, - teiUid = "teiUid", + resultCode = any(), + data = anyOrNull(), + teiUid = anyOrNull(), + enrollmentUid = anyOrNull(), ), ) doReturn SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH - val result = presenter.onRegisterLastResult(resultCode = 1, data = null) - + val result = + presenter.onRegisterLastResult( + resultCode = 1, + data = null, + enrollmentUid = "enrollmentUidExtra", + ) + + verify(simprintsEnrollmentViewModel).onRegisterLastResult( + resultCode = 1, + data = null, + teiUid = "teiUid", + enrollmentUid = "enrollmentUidExtra", + ) assert(result == SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH) } diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt index 011d95def48..bcc7ee39226 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.outlined.List import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.outlined.Map +import androidx.lifecycle.MutableLiveData import androidx.paging.PagingData import androidx.paging.testing.asSnapshot import app.cash.turbine.test @@ -15,6 +16,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take @@ -26,7 +28,6 @@ import org.dhis2.R import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel import org.dhis2.form.model.FieldUiModel @@ -36,16 +37,20 @@ import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.geometry.mapper.EventsByProgramStage import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase import org.dhis2.simprints.SimprintsSearchViewModel import org.dhis2.usescases.searchTrackEntity.listView.SearchResult.SearchResultType import org.dhis2.utils.customviews.navigationbar.NavigationPage import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.program.Program +import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.android.core.trackedentity.TrackedEntityType import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -54,12 +59,14 @@ import org.maplibre.geojson.BoundingBox import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import kotlin.text.get @OptIn(ExperimentalCoroutinesApi::class) class SearchTEIViewModelTest { @@ -82,7 +89,10 @@ class SearchTEIViewModelTest { private val displayNameProvider: DisplayNameProvider = mock() private val filterManager: FilterManager = mock() private val simprintsSearchViewModel: SimprintsSearchViewModel = mock() - private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase = mock() + private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase = mock() + private val simprintsBiometricSearchNavigation = MutableSharedFlow(extraBufferCapacity = 1) + private val simprintsBiometricSearch = MutableLiveData(false) + private val simprintsUseLastBiometricsLabel = MutableLiveData(false) @ExperimentalCoroutinesApi private val testingDispatcher = StandardTestDispatcher() @@ -96,6 +106,12 @@ class SearchTEIViewModelTest { whenever(repository.canCreateInProgramWithoutSearch()) doReturn true whenever(repository.getTrackedEntityType()) doReturn testingTrackedEntityType() whenever(repository.filtersApplyOnGlobalSearch()) doReturn true + whenever(filterManager.stateFilters) doReturn emptyList() + whenever(simprintsSearchViewModel.simprintsBiometricSearchNavigation) doReturn + simprintsBiometricSearchNavigation + whenever(simprintsSearchViewModel.isSimprintsBiometricSearch) doReturn simprintsBiometricSearch + whenever(simprintsSearchViewModel.isSimprintsUseLastBiometricsLabel) doReturn + simprintsUseLastBiometricsLabel viewModel = SearchTEIViewModel( initialProgram, @@ -117,7 +133,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, - orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, + loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) testingDispatcher.scheduler.advanceUntilIdle() } @@ -190,6 +206,28 @@ class SearchTEIViewModelTest { assertTrue(screenState is SearchList) } + @Test + fun `Should keep Search screen open after Simprints biometric no matches when list screen is refreshed`() { + viewModel.onSimprintsBiometricNoMatches() + + viewModel.setListScreen() + + val screenState = viewModel.screenState.value as SearchList + assertTrue(screenState.searchForm.isOpened) + assertFalse(screenState.searchForm.isForced) + } + + @Test + fun `Should stop keeping Search screen open after follow up search`() { + viewModel.onSimprintsBiometricNoMatches() + + viewModel.onSearch() + testingDispatcher.scheduler.advanceUntilIdle() + + val screenState = viewModel.screenState.value as SearchList + assertFalse(screenState.searchForm.isOpened) + } + @Test fun `Should set previous screen`() { viewModel.setListScreen() @@ -336,20 +374,17 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram) setAllowCreateBeforeSearch(false) whenever(networkUtils.isOnline()) doReturn true - whenever(filterManager.stateFilters) doReturn emptyList() - val firstItem = trackedEntitySearchItem("tei-1") - val secondItem = trackedEntitySearchItem("tei-2") val firstModel = searchTeiModel("first") val secondModel = searchTeiModel("second") whenever( - orderSearchResultsByIdentifyResponse( + loadSimprintsBiometricSearchResultsUseCase( + any(), + any(), + any(), any(), anyOrNull(), - any List>(), ), - ) doReturn listOf(secondItem, firstItem) - whenever(repository.transform(secondItem, testingProgram, false, null)) doReturn secondModel - whenever(repository.transform(firstItem, testingProgram, false, null)) doReturn firstModel + ) doReturn flowOf(PagingData.from(listOf(secondModel, firstModel))) viewModel.searchParametersUiState = viewModel.searchParametersUiState.copy( items = listOf(simprintsBiometricSearchField()), @@ -365,7 +400,13 @@ class SearchTEIViewModelTest { viewModel.setSearchScreen() testingDispatcher.scheduler.advanceUntilIdle() - val result = async { viewModel.searchPagingData.drop(1).take(1).asSnapshot() } + val result = + async { + viewModel.searchPagingData + .drop(1) + .take(1) + .asSnapshot() + } viewModel.onSearch() testingDispatcher.scheduler.advanceUntilIdle() @@ -380,14 +421,15 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram) setAllowCreateBeforeSearch(false) whenever(networkUtils.isOnline()) doReturn true - whenever(filterManager.stateFilters) doReturn emptyList() val searchItem = trackedEntitySearchItem("tei-1") val searchModel = searchTeiModel("regular") whenever( - orderSearchResultsByIdentifyResponse( + loadSimprintsBiometricSearchResultsUseCase( + any(), + any(), + any(), any(), anyOrNull(), - any List>(), ), ) doReturn null whenever(repositoryKt.searchTrackedEntities(any(), any())) doReturn @@ -408,7 +450,13 @@ class SearchTEIViewModelTest { viewModel.setSearchScreen() testingDispatcher.scheduler.advanceUntilIdle() - val result = async { viewModel.searchPagingData.drop(1).take(1).asSnapshot() } + val result = + async { + viewModel.searchPagingData + .drop(1) + .take(1) + .asSnapshot() + } viewModel.onSearch() testingDispatcher.scheduler.advanceUntilIdle() @@ -416,6 +464,87 @@ class SearchTEIViewModelTest { verify(repositoryKt).searchTrackedEntities(any(), any()) } + @Test + fun `Should return Simprints possible duplicates search results when query contains multiple GUIDs`() = + runTest { + val testingProgram = testingProgram(displayFrontPageList = false) + setCurrentProgram(testingProgram) + setAllowCreateBeforeSearch(false) + whenever(networkUtils.isOnline()) doReturn true + viewModel.setSimprintsPossibleDuplicatesSearch(true) + + viewModel.queryData["biometric"] = listOf("guid-1", "guid-2") + + val searchItem1 = trackedEntitySearchItem("tei-1") + val searchItem2 = trackedEntitySearchItem("tei-2") + whenever(repositoryKt.searchTrackedEntitiesImmediate(any(), any())).thenAnswer { + val params = it.arguments[0] as SearchParametersModel + when (params.queryData?.get("biometric")?.firstOrNull()) { + "guid-1" -> listOf(searchItem1) + "guid-2" -> listOf(searchItem2) + else -> emptyList() + } + } + + val model1 = searchTeiModel("model-1", teiUid = "tei-1") + val model2 = searchTeiModel("model-2", teiUid = "tei-2") + whenever(repository.transform(searchItem1, testingProgram, false, null)) doReturn model1 + whenever(repository.transform(searchItem2, testingProgram, false, null)) doReturn model2 + + viewModel.setListScreen() + viewModel.setSearchScreen() + testingDispatcher.scheduler.advanceUntilIdle() + + val result = + async { + viewModel.searchPagingData + .drop(1) + .take(1) + .asSnapshot() + } + viewModel.onSearch() + testingDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf(model1, model2), result.await()) + verify(repositoryKt, times(2)).searchTrackedEntitiesImmediate(any(), any()) + verify(repositoryKt, times(0)).searchTrackedEntities(any(), any()) + } + + @Test + fun `Should auto finish Simprints possible duplicates search when there are no DHIS2 matches`() = + runTest { + val testingProgram = testingProgram(displayFrontPageList = false, minAttributesToSearch = 2) + setCurrentProgram(testingProgram) + setAllowCreateBeforeSearch(false) + whenever(networkUtils.isOnline()) doReturn true + viewModel.setSimprintsPossibleDuplicatesSearch(true) + + viewModel.queryData["biometric"] = listOf("guid-1", "guid-2") + whenever(repositoryKt.searchTrackedEntitiesImmediate(any(), any())) doReturn emptyList() + + viewModel.setListScreen() + viewModel.setSearchScreen() + testingDispatcher.scheduler.advanceUntilIdle() + + viewModel.simprintsNavigation.test { + val snapshot = + async { + viewModel.searchPagingData + .drop(1) + .take(1) + .asSnapshot() + } + viewModel.onSearch() + testingDispatcher.scheduler.advanceUntilIdle() + + assertTrue(awaitItem() is SimprintsNavigationAction.AutoEnrollLastBiometricsFromSimprintsPossibleDuplicates) + snapshot.await() + cancelAndIgnoreRemainingEvents() + } + verify(simprintsSearchViewModel, never()).markPendingEnrollmentFromSimprintsPossibleDuplicates() + verify(repositoryKt, times(0)).searchTrackedEntities(any(), any()) + } + @ExperimentalCoroutinesApi @Test fun `Should fetch map results`() { @@ -528,7 +657,7 @@ class SearchTEIViewModelTest { runTest { val intent: Intent = mock() whenever( - simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any()), + simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any(), any()), ) doReturn SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity(intent) viewModel.simprintsNavigation.test { @@ -542,6 +671,34 @@ class SearchTEIViewModelTest { } } + @Test + fun `Should emit open dashboard navigation when Simprints dashboard request resolves directly`() = + runTest { + whenever( + simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any(), any()), + ) doReturn + SimprintsSearchViewModel.DashboardAction.OpenDashboard( + SimprintsSearchViewModel.PendingDashboardNavigation( + teiUid = "teiUid", + programUid = "programUid", + enrollmentUid = "enrollmentUid", + ), + ) + + viewModel.simprintsNavigation.test { + viewModel.onOpenDashboardRequested("teiUid", "programUid", "enrollmentUid") + testingDispatcher.scheduler.advanceUntilIdle() + + val action = awaitItem() + assertTrue(action is SimprintsNavigationAction.OpenDashboard) + action as SimprintsNavigationAction.OpenDashboard + assertEquals("teiUid", action.teiUid) + assertEquals("programUid", action.programUid) + assertEquals("enrollmentUid", action.enrollmentUid) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `Should emit open dashboard navigation after Simprints confirm identity succeeds`() = runTest { @@ -570,7 +727,7 @@ class SearchTEIViewModelTest { fun `Should emit error message when Simprints confirm identity setup fails`() = runTest { whenever( - simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any()), + simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any(), any()), ).thenThrow(RuntimeException()) whenever(resourceManager.getString(R.string.custom_intent_error)) doReturn "Custom intent error" @@ -587,15 +744,91 @@ class SearchTEIViewModelTest { @Test fun `Should refresh Simprints last biometrics label state`() { - whenever(simprintsSearchViewModel.shouldUseLastBiometricsLabel(any())) doReturn true + simprintsUseLastBiometricsLabel.value = true clearInvocations(simprintsSearchViewModel) viewModel.refreshSimprintsUiState() assertTrue(viewModel.isSimprintsUseLastBiometricsLabel.value == true) - verify(simprintsSearchViewModel).clearPendingSessionIfNeeded(any()) + verify(simprintsSearchViewModel).refreshSimprintsUiState(any()) + } + + @Test + fun `Should delegate enrollment query preparation to SimprintsSearchViewModel`() { + val queryData = mapOf("name" to listOf("Name")) + whenever(simprintsSearchViewModel.prepareEnrollmentQueryData(any(), eq(queryData))) doReturn + hashMapOf("name" to listOf("Name")) + + val result = viewModel.prepareEnrollmentQueryData(queryData) + + assertEquals(hashMapOf("name" to listOf("Name")), result) + verify(simprintsSearchViewModel).prepareEnrollmentQueryData(any(), eq(queryData)) } + @Test + fun `Should delegate Simprints biometric identification result`() { + viewModel.onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "guid-1", + hasAutoOpenEligibleSimprintsIdentification = true, + ) + + verify(simprintsSearchViewModel).onSimprintsBiometricIdentificationResult( + uid = "biometric", + value = "guid-1", + hasAutoOpenEligibleSimprintsIdentification = true, + ) + } + + @Test + fun `Should delegate Simprints confirm identity launch failures`() { + viewModel.onConfirmIdentityLaunchFailed() + + verify(simprintsSearchViewModel).onConfirmIdentityLaunchFailed() + } + + @Test + fun `Should rehydrate initial query and enable search when search parameters are fetched`() = + runTest { + whenever(repositoryKt.searchParameters(initialProgram, "teiTypeUid")) doReturn + listOf(simprintsBiometricSearchField()) + val viewModel = + SearchTEIViewModel( + initialProgramUid = initialProgram, + initialQuery = mutableMapOf("biometric" to listOf("guid-1")), + searchRepository = repository, + searchRepositoryKt = repositoryKt, + searchNavPageConfigurator = pageConfigurator, + mapDataRepository = mapDataRepository, + networkUtils = networkUtils, + dispatchers = + object : DispatcherProvider { + override fun io(): CoroutineDispatcher = testingDispatcher + + override fun computation(): CoroutineDispatcher = testingDispatcher + + override fun ui(): CoroutineDispatcher = testingDispatcher + }, + mapStyleConfig = mapStyleConfiguration, + resourceManager = resourceManager, + displayNameProvider = displayNameProvider, + filterManager = filterManager, + simprintsSearchViewModel = simprintsSearchViewModel, + loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, + ) + + viewModel.fetchSearchParameters(initialProgram, "teiTypeUid") + testingDispatcher.scheduler.advanceUntilIdle() + + val fieldUiModel = viewModel.searchParametersUiState.items.single() + + assertEquals("guid-1", fieldUiModel.value) + assertTrue(viewModel.searchParametersUiState.clearSearchEnabled) + assertTrue(viewModel.searchParametersUiState.searchEnabled) + assertTrue(viewModel.searchParametersUiState.searchedItems.isNotEmpty()) + assertTrue(viewModel.screenState.value is SearchList) + } + @Test fun `Should preserve entered search parameter values when parameters are refetched`() = runTest { @@ -615,10 +848,221 @@ class SearchTEIViewModelTest { viewModel.fetchSearchParameters(initialProgram, "teiTypeUid") testingDispatcher.scheduler.advanceUntilIdle() - assertEquals("guid-1", viewModel.searchParametersUiState.items.single().value) - assertEquals("guid-1", viewModel.searchParametersUiState.items.single().displayName) + val fieldUiModel = viewModel.searchParametersUiState.items.single() + + assertEquals("guid-1", fieldUiModel.value) + assertEquals("guid-1", fieldUiModel.displayName) + } + + @Test + fun `Should forward Simprints biometric search navigation flow`() = + runTest { + viewModel.simprintsBiometricSearchNavigation.test { + simprintsBiometricSearchNavigation.emit(Unit) + + awaitItem() + cancelAndIgnoreRemainingEvents() + } } + @Test + fun `Should switch to list screen and clear fetched results for Simprints biometric search navigation`() { + viewModel.onNavigationPageChanged(NavigationPage.MAP_VIEW) + viewModel.setMapScreen() + viewModel.queryData["testingUid"] = listOf("testingValue") + clearInvocations(repository) + + viewModel.onSimprintsBiometricSearchNavigation() + testingDispatcher.scheduler.advanceUntilIdle() + + assertEquals(NavigationPage.LIST_VIEW, viewModel.navigationBarUIState.value.selectedItem) + assertTrue(viewModel.screenState.value?.screenState == SearchScreenState.LIST) + verify(repository).clearFetchedList() + } + + @Test + fun `Should open dashboard when Simprints save resolves direct navigation`() = + runTest { + whenever( + simprintsSearchViewModel.onSimprintsParameterSaved( + any(), + anyOrNull(), + any(), + any(), + any(), + ), + ) doReturn + SimprintsSearchViewModel.PendingDashboardNavigation( + teiUid = "teiUid", + programUid = "matchedProgramUid", + enrollmentUid = "enrollmentUid", + isOnline = true, + ) + viewModel.onNavigationPageChanged(NavigationPage.MAP_VIEW) + viewModel.setMapScreen() + viewModel.queryData["existingQuery"] = listOf("value") + + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = + listOf( + simprintsBiometricSearchField().copy( + value = "guid-1", + displayName = "guid-1", + ), + ), + ) + + viewModel.onParameterIntent( + FormIntent.OnSave( + uid = "biometric", + value = "guid-1", + valueType = ValueType.TEXT, + ), + ) + testingDispatcher.scheduler.advanceUntilIdle() + + val action = viewModel.legacyInteraction.value + assertTrue(action is LegacyInteraction.OnTeiClick) + action as LegacyInteraction.OnTeiClick + assertEquals("teiUid", action.teiUid) + assertEquals("enrollmentUid", action.enrollmentUid) + assertTrue(action.online) + assertEquals(NavigationPage.LIST_VIEW, viewModel.navigationBarUIState.value.selectedItem) + assertEquals(SearchScreenState.LIST, viewModel.screenState.value?.screenState) + assertTrue(viewModel.queryData.isEmpty()) + assertTrue(!viewModel.searchParametersUiState.clearSearchEnabled) + assertTrue(viewModel.searchParametersUiState.searchedItems.isEmpty()) + val searchItem = viewModel.searchParametersUiState.items.single() + assertNull(searchItem.value) + assertNull(searchItem.displayName) + } + + @Test + fun `Should return Simprints biometric global results when available`() = + runTest { + setCurrentProgram(testingProgram(displayFrontPageList = false)) + setAllowCreateBeforeSearch(false) + whenever(networkUtils.isOnline()) doReturn true + val globalModel = searchTeiModel("global") + whenever( + loadSimprintsBiometricSearchResultsUseCase( + any(), + any(), + any(), + any(), + anyOrNull(), + ), + ) doReturn flowOf(PagingData.from(listOf(globalModel))) + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = listOf(simprintsBiometricSearchField()), + ) + viewModel.onParameterIntent( + FormIntent.OnSave( + uid = "biometric", + value = "guid-1,guid-2", + valueType = ValueType.TEXT, + ), + ) + viewModel.setListScreen() + viewModel.setSearchScreen() + viewModel.onSearch() + testingDispatcher.scheduler.advanceUntilIdle() + + val result = viewModel.fetchGlobalResults()?.asSnapshot() + + assertEquals(listOf(globalModel), result) + } + + @Test + fun `Should delegate Simprints biometric save handling to SimprintsSearchViewModel`() = + runTest { + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = listOf(simprintsBiometricSearchField()), + ) + + whenever( + simprintsSearchViewModel.onSimprintsParameterSaved( + any(), + anyOrNull(), + any(), + any(), + any(), + ), + ) doAnswer { + simprintsBiometricSearchNavigation.tryEmit(Unit) + null + } + + viewModel.onParameterIntent( + FormIntent.OnSave( + uid = "biometric", + value = "guid-1", + valueType = ValueType.TEXT, + ), + ) + testingDispatcher.scheduler.advanceUntilIdle() + + verify(simprintsSearchViewModel).onSimprintsParameterSaved( + eq("biometric"), + eq("guid-1"), + any(), + any(), + any(), + ) + } + + @Test + fun `Should clear only Simprints biometric query data when requested to do so`() { + val biometricField = + simprintsBiometricSearchField().copy( + value = "guid-1", + displayName = "guid-1", + ) + val textField = + FieldUiModelImpl( + uid = "name", + label = "Name", + value = "Name", + displayName = "Name", + autocompleteList = emptyList(), + optionSetConfiguration = null, + valueType = ValueType.TEXT, + ) + viewModel.queryData["biometric"] = listOf("guid-1") + viewModel.queryData["name"] = listOf("Name") + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = listOf(biometricField, textField), + ) + whenever( + simprintsSearchViewModel.clearSimprintsBiometricQueryData( + any(), + any(), + ), + ) doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + val queryData = invocation.arguments[1] as MutableMap?> + queryData.remove("biometric") + listOf( + biometricField.copy(value = null, displayName = null), + textField, + ) + } + + viewModel.clearSimprintsBiometricQueryData() + + assertEquals(mapOf("name" to listOf("Name")), viewModel.queryData) + val biometricFieldUiModel = viewModel.searchParametersUiState.items.first() + + assertEquals(null, biometricFieldUiModel.value) + assertEquals("Name", viewModel.searchParametersUiState.items[1].value) + assertEquals(mapOf("name" to "Name"), viewModel.searchParametersUiState.searchedItems) + verify(simprintsSearchViewModel).clearSimprintsBiometricQueryData(any(), any()) + } + @Test fun `Should enroll on click`() { viewModel.onEnrollClick() @@ -941,6 +1385,7 @@ class SearchTEIViewModelTest { assert(viewModel.queryData.isEmpty()) assert(viewModel.searchParametersUiState.items.all { it.value == null }) assert(viewModel.searchParametersUiState.searchedItems.isEmpty()) + verify(simprintsSearchViewModel).clearPendingSession() } @Test @@ -991,7 +1436,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, - orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, + loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -1039,7 +1484,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, - orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, + loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -1191,10 +1636,19 @@ class SearchTEIViewModelTest { header = uid, ) - private fun searchTeiModel(header: String) = - SearchTeiModel().apply { - setHeader(header) - } + private fun searchTeiModel( + header: String, + teiUid: String = header, + ) = SearchTeiModel().apply { + setHeader(header) + tei = + TrackedEntityInstance + .builder() + .uid(teiUid) + .trackedEntityType("teiType") + .organisationUnit("orgUnit") + .build() + } private fun testingProgram( displayFrontPageList: Boolean = true, diff --git a/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt index 01d8e431467..b6ab78934c8 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt @@ -1,10 +1,16 @@ package org.dhis2.commons.simprints.repository import org.dhis2.commons.prefs.PreferenceProvider +import timber.log.Timber class SimprintsSessionRepository( private val preferenceProvider: PreferenceProvider, ) { + internal enum class PendingEnrollmentSource { + BIOMETRIC_SEARCH, + POSSIBLE_DUPLICATES, + } + fun save(sessionId: String) { preferenceProvider.setValue(LAST_IDENTIFICATION_SESSION_ID, sessionId) clearPendingEnrollment() @@ -17,6 +23,14 @@ class SimprintsSessionRepository( fun markPendingEnrollment() { if (hasPendingSession()) { preferenceProvider.setValue(PENDING_ENROLL_LAST, true) + preferenceProvider.setValue(PENDING_ENROLL_LAST_SOURCE, PendingEnrollmentSource.BIOMETRIC_SEARCH.name) + } + } + + fun markPendingEnrollmentFromPossibleDuplicates() { + if (hasPendingSession()) { + preferenceProvider.setValue(PENDING_ENROLL_LAST, true) + preferenceProvider.setValue(PENDING_ENROLL_LAST_SOURCE, PendingEnrollmentSource.POSSIBLE_DUPLICATES.name) } } @@ -27,7 +41,22 @@ class SimprintsSessionRepository( fun hasPendingEnrollment(): Boolean = pendingEnrollmentSessionId() != null - fun clearPendingEnrollment() = preferenceProvider.removeValue(PENDING_ENROLL_LAST) + fun hasPendingEnrollmentFromPossibleDuplicates(): Boolean = + hasPendingEnrollment() && + preferenceProvider.getString(PENDING_ENROLL_LAST_SOURCE) + ?.let { + try { + PendingEnrollmentSource.valueOf(it) + } catch (e: Exception) { + Timber.e(e, "Failed to parse Simprints pending enrollment source") + null + } + } == PendingEnrollmentSource.POSSIBLE_DUPLICATES + + fun clearPendingEnrollment() { + preferenceProvider.removeValue(PENDING_ENROLL_LAST) + preferenceProvider.removeValue(PENDING_ENROLL_LAST_SOURCE) + } fun clear() { preferenceProvider.removeValue(LAST_IDENTIFICATION_SESSION_ID) @@ -37,5 +66,6 @@ class SimprintsSessionRepository( internal companion object { internal const val LAST_IDENTIFICATION_SESSION_ID = "SID_LAST_IDENTIFICATION_SESSION_ID" internal const val PENDING_ENROLL_LAST = "SID_PENDING_ENROLL_LAST" + internal const val PENDING_ENROLL_LAST_SOURCE = "SID_PENDING_ENROLL_LAST_SOURCE" } } diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCase.kt new file mode 100644 index 00000000000..2a8d0a54906 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCase.kt @@ -0,0 +1,79 @@ +package org.dhis2.commons.simprints.usecases + +import android.os.Bundle +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import timber.log.Timber + +class SimprintsExtractIdentificationMatchesUseCase { + data class SimprintsIdentificationMatch( + val guid: String, + val confidence: Float? = null, + ) + + operator fun invoke(extras: Bundle?): List = + extras?.getString(SIMPRINTS_IDENTIFICATION_EXTRA_NAME)?.takeIf(String::isNotBlank) + ?.let { jsonString -> + parseJsonElementOrNull(jsonString) + ?.let(::extractMatches) + .orEmpty() + .distinctBy(SimprintsIdentificationMatch::guid) + } + ?: emptyList() + + private fun parseJsonElementOrNull(jsonString: String): JsonElement? = + try { + JsonParser.parseString(jsonString) + } catch (e: Exception) { + Timber.e(e, "Failed to parse JSON element in Simprints identification response") + null + } + + private fun extractMatches(element: JsonElement): List = + when { + element.isJsonArray -> + element.asJsonArray.flatMap { child -> + extractMatches(child) + } + + element.isJsonObject -> + element.asJsonObject.asMatchOrNull()?.let(::listOf) + ?: element.asJsonObject.entrySet() + .flatMap { (_, value) -> extractMatches(value) } + + else -> emptyList() + } + + private fun JsonObject.asMatchOrNull(): SimprintsIdentificationMatch? { + val guid = + get(SIMPRINTS_GUID_KEY) + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf(String::isNotBlank) + ?: return null + + val confidence = + get(SIMPRINTS_CONFIDENCE_KEY) + ?.takeIf { !it.isJsonNull } + ?.let { value -> + try { + value.asFloat + } catch (e: Exception) { + Timber.e(e, "Failed to parse confidence value in Simprints identification response") + null + } + } + + return SimprintsIdentificationMatch( + guid = guid, + confidence = confidence, + ) + } + + private companion object { + private const val SIMPRINTS_IDENTIFICATION_EXTRA_NAME = "identification" + private const val SIMPRINTS_GUID_KEY = "guid" + private const val SIMPRINTS_CONFIDENCE_KEY = "confidence" + } +} diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCase.kt new file mode 100644 index 00000000000..389f605c8df --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCase.kt @@ -0,0 +1,63 @@ +package org.dhis2.commons.simprints.usecases + +import android.os.Bundle +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import timber.log.Timber + +class SimprintsHasAutoOpenEligibleIdentificationUseCase { + operator fun invoke(extras: Bundle?): Boolean = + extras + ?.keySet() + ?.asSequence() + ?.mapNotNull { key -> extras.get(key) } + ?.mapNotNull(::parseSimprintsJsonElement) + ?.any(::containsAutoOpenEligibleIdentification) + ?: false + + private fun parseSimprintsJsonElement(extraValue: Any): JsonElement? = + when (extraValue) { + is String -> extraValue.takeIf(String::isNotBlank)?.let(::parseJsonElementOrNull) + is CharSequence -> extraValue.toString().takeIf(String::isNotBlank)?.let(::parseJsonElementOrNull) + else -> null + } + + private fun parseJsonElementOrNull(jsonString: String): JsonElement? = + try { + JsonParser.parseString(jsonString) + } catch (e: JsonSyntaxException) { + Timber.e(e, "Failed to parse JSON element in Simprints identification response") + null + } + + private fun containsAutoOpenEligibleIdentification(element: JsonElement): Boolean = + when { + element.isJsonArray -> + element.asJsonArray.any(::containsAutoOpenEligibleIdentification) + + element.isJsonObject -> + containsAutoOpenEligibleIdentification(element.asJsonObject) + + else -> false + } + + private fun containsAutoOpenEligibleIdentification(jsonObject: JsonObject): Boolean = + hasAutoOpenEligibleIdentification(jsonObject) || + jsonObject.entrySet().any { (_, value) -> containsAutoOpenEligibleIdentification(value) } + + private fun hasAutoOpenEligibleIdentification(jsonObject: JsonObject): Boolean = + jsonObject.booleanOrNull(KEY_IS_LINKED_TO_CREDENTIAL) == true && + jsonObject.booleanOrNull(KEY_IS_VERIFIED) != false + + private fun JsonObject.booleanOrNull(key: String): Boolean? = + get(key) + ?.takeIf(JsonElement::isJsonPrimitive) + ?.asBoolean + + private companion object { + private const val KEY_IS_LINKED_TO_CREDENTIAL = "isLinkedToCredential" + private const val KEY_IS_VERIFIED = "isVerified" + } +} diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt index 41a2f1c9256..9a088e3ecdb 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt @@ -11,12 +11,13 @@ class SimprintsResolveConfirmIdentityCalloutUseCase( teiUid: String, searchFields: Iterable, sessionId: String, + allowBlankSearchValue: Boolean = false, ): SimprintsIntentUtils.PreparedCallout? = searchFields.firstNotNullOfOrNull { field -> val customIntent = field.customIntent val selectedGuid = simprintsD2Repository.getTrackedEntityAttributeValue(teiUid, field.uid) when { - field.value.isNullOrBlank() -> null + field.value.isNullOrBlank() && !allowBlankSearchValue -> null customIntent == null -> null !SimprintsIntentUtils.isIdentifyCallout(customIntent) -> null selectedGuid.isNullOrBlank() -> null diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePossibleDuplicatesSearchUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePossibleDuplicatesSearchUseCase.kt new file mode 100644 index 00000000000..b839841a29a --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePossibleDuplicatesSearchUseCase.kt @@ -0,0 +1,39 @@ +package org.dhis2.commons.simprints.usecases + +import android.app.Activity +import android.content.Intent +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils + +class SimprintsResolvePossibleDuplicatesSearchUseCase( + private val extractIdentificationMatches: SimprintsExtractIdentificationMatchesUseCase, + private val sessionRepository: SimprintsSessionRepository, +) { + data class SimprintsPossibleDuplicatesSearch( + val fieldUid: String, + val guidValues: List, + ) + + operator fun invoke( + fieldUid: String, + resultCode: Int, + data: Intent?, + ): SimprintsPossibleDuplicatesSearch? { + if (resultCode != Activity.RESULT_OK) return null + + val sessionId = SimprintsIntentUtils.extractSessionId(data?.extras) ?: return null + val guidValues = + extractIdentificationMatches(data?.extras) + .map { it.guid } + .filter(String::isNotBlank) + .distinct() + if (guidValues.isEmpty()) return null + + sessionRepository.save(sessionId) + + return SimprintsPossibleDuplicatesSearch( + fieldUid = fieldUid, + guidValues = guidValues, + ) + } +} diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsSessionRepositoryTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsSessionRepositoryTest.kt index 67e35a34ab9..e0d36757943 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsSessionRepositoryTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsSessionRepositoryTest.kt @@ -23,6 +23,7 @@ class SimprintsSessionRepositoryTest { "session-id", ) verify(preferenceProvider).removeValue(SimprintsSessionRepository.PENDING_ENROLL_LAST) + verify(preferenceProvider).removeValue(SimprintsSessionRepository.PENDING_ENROLL_LAST_SOURCE) } @Test @@ -37,6 +38,10 @@ class SimprintsSessionRepositoryTest { repository.markPendingEnrollment() verify(preferenceProvider).setValue(SimprintsSessionRepository.PENDING_ENROLL_LAST, true) + verify(preferenceProvider).setValue( + SimprintsSessionRepository.PENDING_ENROLL_LAST_SOURCE, + SimprintsSessionRepository.PendingEnrollmentSource.BIOMETRIC_SEARCH.name, + ) } @Test @@ -54,6 +59,49 @@ class SimprintsSessionRepositoryTest { SimprintsSessionRepository.PENDING_ENROLL_LAST, true, ) + verify(preferenceProvider, never()).setValue( + SimprintsSessionRepository.PENDING_ENROLL_LAST_SOURCE, + SimprintsSessionRepository.PendingEnrollmentSource.BIOMETRIC_SEARCH.name, + ) + } + + @Test + fun `markPendingEnrollmentFromPossibleDuplicates should persist flag only when session exists`() { + whenever( + preferenceProvider.getString( + SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID, + null, + ), + ) doReturn "session-id" + + repository.markPendingEnrollmentFromPossibleDuplicates() + + verify(preferenceProvider).setValue(SimprintsSessionRepository.PENDING_ENROLL_LAST, true) + verify(preferenceProvider).setValue( + SimprintsSessionRepository.PENDING_ENROLL_LAST_SOURCE, + SimprintsSessionRepository.PendingEnrollmentSource.POSSIBLE_DUPLICATES.name, + ) + } + + @Test + fun `markPendingEnrollmentFromPossibleDuplicates should not persist flag when session is missing`() { + whenever( + preferenceProvider.getString( + SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID, + null, + ), + ) doReturn null + + repository.markPendingEnrollmentFromPossibleDuplicates() + + verify(preferenceProvider, never()).setValue( + SimprintsSessionRepository.PENDING_ENROLL_LAST, + true, + ) + verify(preferenceProvider, never()).setValue( + SimprintsSessionRepository.PENDING_ENROLL_LAST_SOURCE, + SimprintsSessionRepository.PendingEnrollmentSource.POSSIBLE_DUPLICATES.name, + ) } @Test @@ -96,5 +144,6 @@ class SimprintsSessionRepositoryTest { verify(preferenceProvider).removeValue(SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID) verify(preferenceProvider).removeValue(SimprintsSessionRepository.PENDING_ENROLL_LAST) + verify(preferenceProvider).removeValue(SimprintsSessionRepository.PENDING_ENROLL_LAST_SOURCE) } } diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCaseTest.kt new file mode 100644 index 00000000000..616dfd57112 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCaseTest.kt @@ -0,0 +1,40 @@ +package org.dhis2.commons.simprints.usecases + +import org.dhis2.commons.simprints.usecases.SimprintsExtractIdentificationMatchesUseCase.SimprintsIdentificationMatch +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SimprintsExtractIdentificationMatchesUseCaseTest { + private val useCase = SimprintsExtractIdentificationMatchesUseCase() + + @Test + fun `invoke should extract matches from known identification payload key`() { + val extras: android.os.Bundle = mock() + whenever(extras.getString("identification")) + .thenReturn("""[{"guid":"g1","confidence":0.9},{"guid":"g2"}]""") + + val result = useCase(extras) + + assertEquals( + listOf( + SimprintsIdentificationMatch(guid = "g1", confidence = 0.9f), + SimprintsIdentificationMatch(guid = "g2", confidence = null), + ), + result, + ) + } + + @Test + fun `invoke should return empty list when payload is missing`() { + val extras: android.os.Bundle = mock() + whenever(extras.getString("identification")).thenReturn(null) + whenever(extras.getString("identifications")).thenReturn(null) + whenever(extras.keySet()).thenReturn(emptySet()) + + val result = useCase(extras) + + assertEquals(emptyList(), result) + } +} diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCaseTest.kt new file mode 100644 index 00000000000..7be139bb917 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCaseTest.kt @@ -0,0 +1,50 @@ +package org.dhis2.commons.simprints.usecases + +import android.os.Bundle +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class SimprintsHasAutoOpenEligibleIdentificationUseCaseTest { + private val useCase = SimprintsHasAutoOpenEligibleIdentificationUseCase() + + @Test + fun `invoke should return true when nested extras contain an eligible identification`() { + val extras = + mock { + on { keySet() } doReturn setOf("response") + on { get("response") } doReturn + """{"results":[{"isLinkedToCredential":true,"isVerified":true}]}""" + } + + assertTrue(useCase(extras)) + } + + @Test + fun `invoke should return false when identification is explicitly not verified`() { + val extras = + mock { + on { keySet() } doReturn setOf("response") + on { get("response") } doReturn + """{"results":[{"isLinkedToCredential":true,"isVerified":false}]}""" + } + + assertFalse(useCase(extras)) + } + + @Test + fun `invoke should ignore null unparsable and non json extras`() { + val extras = + mock { + on { keySet() } doReturn setOf("invalid", "blank", "count") + on { get("invalid") } doReturn "{not-json" + on { get("blank") } doReturn " " + on { get("count") } doReturn 1 + } + + assertFalse(useCase(null)) + assertFalse(useCase(extras)) + } +} diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt index 92060fe2bd7..e49d2675def 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt @@ -89,6 +89,50 @@ class SimprintsResolveConfirmIdentityCalloutUseCaseTest { assertNull(result) } + @Test + fun `invoke should return confirm identity callout with blank search value when allowed`() = + runBlocking { + val customIntent = identifyIntent() + whenever( + repository.getTrackedEntityAttributeValue( + "tei-uid", + "biometric", + ), + ) doReturn "selected-guid" + val useCase = + SimprintsResolveConfirmIdentityCalloutUseCase( + simprintsD2Repository = repository, + ) + + val intentActions = mutableListOf() + Mockito.mockConstruction(Intent::class.java) { _, context -> + intentActions.add(context.arguments().firstOrNull() as? String) + }.use { construction -> + val result = + useCase( + teiUid = "tei-uid", + searchFields = + listOf( + SimprintsSearchUtils.SearchField( + uid = "biometric", + value = null, + customIntent = customIntent, + ), + ), + sessionId = "session-id", + allowBlankSearchValue = true, + ) + + assertNotNull(result) + val launchIntent = construction.constructed().single() + assertEquals(listOf("com.simprints.id.CONFIRM_IDENTITY"), intentActions) + assertSame(launchIntent, result!!.launchIntent) + verify(launchIntent).putExtra("sessionId", "session-id") + verify(launchIntent).putExtra("selectedGuid", "selected-guid") + assertEquals(customIntent.customIntentResponse, result.responseData) + } + } + @Test fun `invoke should return null when selected guid is missing`() = runBlocking { diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePossibleDuplicatesSearchUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePossibleDuplicatesSearchUseCaseTest.kt new file mode 100644 index 00000000000..f59ca463e77 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePossibleDuplicatesSearchUseCaseTest.kt @@ -0,0 +1,83 @@ +package org.dhis2.commons.simprints.usecases + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSearchUseCase.SimprintsPossibleDuplicatesSearch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class SimprintsResolvePossibleDuplicatesSearchUseCaseTest { + private val extractIdentificationMatches = SimprintsExtractIdentificationMatchesUseCase() + private val sessionRepository: SimprintsSessionRepository = mock() + + private val useCase = + SimprintsResolvePossibleDuplicatesSearchUseCase( + extractIdentificationMatches = extractIdentificationMatches, + sessionRepository = sessionRepository, + ) + + @Test + fun `invoke should save session and return search when identification matches exist`() { + val extras: Bundle = mock() + whenever(extras.getString("sessionId")) doReturn "session-id" + whenever(extras.getString("identification")) doReturn """[{"guid":"g1"},{"guid":"g2"}]""" + val intent: Intent = mock() + whenever(intent.extras) doReturn extras + + val result = + useCase( + fieldUid = "attribute-uid", + resultCode = Activity.RESULT_OK, + data = intent, + ) + + assertEquals( + SimprintsPossibleDuplicatesSearch( + fieldUid = "attribute-uid", + guidValues = listOf("g1", "g2"), + ), + result, + ) + verify(sessionRepository).save("session-id") + } + + @Test + fun `invoke should return null when result is not ok`() { + val result = + useCase( + fieldUid = "attribute-uid", + resultCode = Activity.RESULT_CANCELED, + data = mock(), + ) + + assertNull(result) + verifyNoInteractions(sessionRepository) + } + + @Test + fun `invoke should return null when session id is missing`() { + val extras: Bundle = mock() + whenever(extras.getString("sessionId")) doReturn null + whenever(extras.getString("identification")) doReturn """[{"guid":"g1"}]""" + val intent: Intent = mock() + whenever(intent.extras) doReturn extras + + val result = + useCase( + fieldUid = "attribute-uid", + resultCode = Activity.RESULT_OK, + data = intent, + ) + + assertNull(result) + verifyNoInteractions(sessionRepository) + } +} diff --git a/form/src/main/java/org/dhis2/form/simprints/SimprintsPossibleDuplicatesSearchHandler.kt b/form/src/main/java/org/dhis2/form/simprints/SimprintsPossibleDuplicatesSearchHandler.kt new file mode 100644 index 00000000000..4cc0a9ce4b2 --- /dev/null +++ b/form/src/main/java/org/dhis2/form/simprints/SimprintsPossibleDuplicatesSearchHandler.kt @@ -0,0 +1,9 @@ +package org.dhis2.form.simprints + +import androidx.compose.runtime.compositionLocalOf +import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSearchUseCase.SimprintsPossibleDuplicatesSearch + +typealias SimprintsPossibleDuplicatesSearchHandler = (SimprintsPossibleDuplicatesSearch) -> Unit + +val LocalSimprintsPossibleDuplicatesSearchHandler = + compositionLocalOf { null } diff --git a/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt b/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt index 7e67ad43a69..25e87df6809 100644 --- a/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt +++ b/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt @@ -28,7 +28,12 @@ fun rememberSimprintsCustomIntentFormPresenter( ?.takeIf(SimprintsIntentUtils::isCallout) ?.let(SimprintsIntentUtils::prepareCallout) } - val placeholderValue = resources.getString(R.string.from_last_biometric_search) + val placeholderValue = + if (sessionRepository.hasPendingEnrollmentFromPossibleDuplicates()) { + resources.getString(R.string.simprints_using_last_biometrics) + } else { + resources.getString(R.string.simprints_from_last_biometric_search) + } return remember( fieldUiModel.value, diff --git a/form/src/main/java/org/dhis2/form/ui/FormView.kt b/form/src/main/java/org/dhis2/form/ui/FormView.kt index 443da35b29d..fa5c4a4e82d 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormView.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormView.kt @@ -41,6 +41,8 @@ import org.dhis2.commons.extensions.serializable import org.dhis2.commons.locationprovider.LocationProvider import org.dhis2.commons.orgunitselector.OUTreeFragment import org.dhis2.commons.periods.ui.PeriodSelectorContent +import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSearchUseCase.SimprintsPossibleDuplicatesSearch +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.form.R import org.dhis2.form.data.RulesUtilsProviderConfigurationError import org.dhis2.form.data.scan.ScanContract @@ -77,6 +79,8 @@ class FormView : Fragment() { private var onFocused: (() -> Unit)? = null private var onFinishDataEntry: (() -> Unit)? = null private var onActivityForResult: (() -> Unit)? = null + private var onLaunchSimprintsCustomIntent: ((CustomIntentInput) -> Unit)? = null + private var onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)? = null private var completionListener: ((percentage: Float) -> Unit)? = null private var onFieldItemsRendered: ((fieldsEmpty: Boolean) -> Unit)? = null private var actionIconsActivate: Boolean = true @@ -132,34 +136,12 @@ class FormView : Fragment() { } } + private val customIntentActivityResultContract = CustomIntentActivityResultContract() private var openCustomIntentLauncher = registerForActivityResult( - CustomIntentActivityResultContract(), + customIntentActivityResultContract, ) { - when (it) { - is CustomIntentResult.Error -> { - val loadingIntent = FormIntent.OnFieldFinishedLoadingData(it.fieldUid) - intentHandler(loadingIntent) - val intent = - FormIntent.OnSaveCustomIntent( - it.fieldUid, - null, - true, - ) - intentHandler(intent) - } - is CustomIntentResult.Success -> { - val loadingIntent = FormIntent.OnFieldFinishedLoadingData(it.fieldUid) - intentHandler(loadingIntent) - val intent = - FormIntent.OnSaveCustomIntent( - it.fieldUid, - it.value, - false, - ) - intentHandler(intent) - } - } + handleCustomIntentResult(it) } private val viewModel: FormViewModel by viewModels { @@ -172,6 +154,9 @@ class FormView : Fragment() { useCompose = useCompose, ) } + private val simprintsSessionRepository by lazy { + Injector.provideSimprintsSessionRepository(requireContext().applicationContext) + } private lateinit var formSectionMapper: FormSectionMapper var scrollCallback: ((Boolean) -> Unit)? = null private var displayConfErrors = true @@ -356,15 +341,23 @@ class FormView : Fragment() { uiEvent.customIntent?.let { val updatedRequestParams = viewModel.getCustomIntentRequestParams(it.uid) val updatedCustomIntent = uiEvent.customIntent.copy(customIntentRequest = updatedRequestParams) + if (SimprintsIntentUtils.isCallout(updatedCustomIntent)) { + simprintsSessionRepository.clear() + } val intent = FormIntent.OnFieldLoadingData(uiEvent.uid) intentHandler(intent) - openCustomIntentLauncher.launch( + val input = CustomIntentInput( fieldUid = uiEvent.uid, customIntent = updatedCustomIntent, defaultTitle = resources.getString(R.string.select_app_intent), - ), - ) + ) + if (SimprintsIntentUtils.isCallout(updatedCustomIntent) && onLaunchSimprintsCustomIntent != null) { + onActivityForResult?.invoke() + onLaunchSimprintsCustomIntent?.invoke(input) + } else { + openCustomIntentLauncher.launch(input) + } } } @@ -620,6 +613,8 @@ class FormView : Fragment() { onFocused: (() -> Unit)?, onFinishDataEntry: (() -> Unit)?, onActivityForResult: (() -> Unit)?, + onLaunchSimprintsCustomIntent: ((CustomIntentInput) -> Unit)?, + onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)?, onFieldItemsRendered: ((fieldsEmpty: Boolean) -> Unit)?, ) { this.onItemChangeListener = onItemChangeListener @@ -627,9 +622,61 @@ class FormView : Fragment() { this.onFocused = onFocused this.onFinishDataEntry = onFinishDataEntry this.onActivityForResult = onActivityForResult + this.onLaunchSimprintsCustomIntent = onLaunchSimprintsCustomIntent + this.onLaunchSimprintsPossibleDuplicatesSearch = onLaunchSimprintsPossibleDuplicatesSearch this.onFieldItemsRendered = onFieldItemsRendered } + fun handleCustomIntentResult(result: CustomIntentResult) { + when (result) { + is CustomIntentResult.Error -> { + val loadingIntent = FormIntent.OnFieldFinishedLoadingData(result.fieldUid) + intentHandler(loadingIntent) + val intent = + FormIntent.OnSaveCustomIntent( + result.fieldUid, + null, + true, + ) + intentHandler(intent) + } + is CustomIntentResult.Success -> { + val loadingIntent = FormIntent.OnFieldFinishedLoadingData(result.fieldUid) + intentHandler(loadingIntent) + val intent = + FormIntent.OnSaveCustomIntent( + result.fieldUid, + result.value, + false, + ) + intentHandler(intent) + } + is CustomIntentResult.PossibleDuplicates -> { + val loadingIntent = FormIntent.OnFieldFinishedLoadingData(result.fieldUid) + intentHandler(loadingIntent) + val sessionId = SimprintsIntentUtils.extractSessionId(result.extras) + if (sessionId != null && onLaunchSimprintsPossibleDuplicatesSearch != null) { + simprintsSessionRepository.save(sessionId) + onActivityForResult?.invoke() + onLaunchSimprintsPossibleDuplicatesSearch?.invoke( + SimprintsPossibleDuplicatesSearch( + fieldUid = result.fieldUid, + guidValues = result.guidValues, + ), + ) + } else { + val intent = + FormIntent.OnSaveCustomIntent( + result.fieldUid, + null, + true, + ) + intentHandler(intent) + } + } + } + } + class Builder { private var records: FormRepositoryRecords? = null private var fragmentManager: FragmentManager? = null @@ -639,6 +686,8 @@ class FormView : Fragment() { private var onFocused: (() -> Unit)? = null private var onActivityForResult: (() -> Unit)? = null private var onFinishDataEntry: (() -> Unit)? = null + private var onLaunchSimprintsCustomIntent: ((CustomIntentInput) -> Unit)? = null + private var onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)? = null private var onPercentageUpdate: ((percentage: Float) -> Unit)? = null private var onFieldItemsRendered: ((fieldsEmpty: Boolean) -> Unit)? = null private var actionIconsActive: Boolean = true @@ -673,6 +722,11 @@ class FormView : Fragment() { fun onFinishDataEntry(callback: () -> Unit) = apply { this.onFinishDataEntry = callback } + fun onLaunchSimprintsCustomIntent(callback: (CustomIntentInput) -> Unit) = apply { this.onLaunchSimprintsCustomIntent = callback } + + fun onLaunchSimprintsPossibleDuplicatesSearch(callback: (SimprintsPossibleDuplicatesSearch) -> Unit) = + apply { this.onLaunchSimprintsPossibleDuplicatesSearch = callback } + fun onPercentageUpdate(callback: (percentage: Float) -> Unit) = apply { this.onPercentageUpdate = callback } fun setRecords(records: FormRepositoryRecords) = apply { this.records = records } @@ -696,6 +750,8 @@ class FormView : Fragment() { onFocused, onFinishDataEntry, onActivityForResult, + onLaunchSimprintsCustomIntent, + onLaunchSimprintsPossibleDuplicatesSearch, onPercentageUpdate, onFieldItemsRendered, actionIconsActive, diff --git a/form/src/main/java/org/dhis2/form/ui/FormViewFragmentFactory.kt b/form/src/main/java/org/dhis2/form/ui/FormViewFragmentFactory.kt index a792a337722..4c1e6531f84 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewFragmentFactory.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewFragmentFactory.kt @@ -3,7 +3,9 @@ package org.dhis2.form.ui import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import org.dhis2.commons.locationprovider.LocationProvider +import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSearchUseCase.SimprintsPossibleDuplicatesSearch import org.dhis2.form.model.RowAction +import org.dhis2.form.ui.customintent.CustomIntentInput class FormViewFragmentFactory( val locationProvider: LocationProvider?, @@ -12,6 +14,8 @@ class FormViewFragmentFactory( private val onFocused: (() -> Unit)?, private val onFinishDataEntry: (() -> Unit)?, private val onActivityForResult: (() -> Unit)?, + private val onLaunchSimprintsCustomIntent: ((CustomIntentInput) -> Unit)?, + private val onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)?, private val completionListener: ((percentage: Float) -> Unit)?, private val onFieldItemsRendered: ((fieldsEmpty: Boolean) -> Unit)?, private val actionIconsActivate: Boolean = true, @@ -31,6 +35,8 @@ class FormViewFragmentFactory( onFocused = onFocused, onFinishDataEntry = onFinishDataEntry, onActivityForResult = onActivityForResult, + onLaunchSimprintsCustomIntent = onLaunchSimprintsCustomIntent, + onLaunchSimprintsPossibleDuplicatesSearch = onLaunchSimprintsPossibleDuplicatesSearch, onFieldItemsRendered = onFieldItemsRendered, ) setConfiguration( diff --git a/form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt b/form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt index dfac6e96b0b..2b84c2b7616 100644 --- a/form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt +++ b/form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt @@ -3,10 +3,12 @@ package org.dhis2.form.ui.customintent import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent +import android.os.Bundle import androidx.activity.result.contract.ActivityResultContract -import androidx.compose.runtime.mutableStateListOf import com.google.gson.Gson import com.google.gson.JsonObject +import com.google.gson.reflect.TypeToken +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.mobile.commons.model.CustomIntentModel import org.dhis2.mobile.commons.model.CustomIntentRequestArgumentModel import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel @@ -15,9 +17,11 @@ import timber.log.Timber import kotlin.collections.forEach class CustomIntentActivityResultContract : ActivityResultContract() { + private var pendingResultContext: CustomIntentResultContext? = null + companion object { - private var fieldUid: String = "" - private var customIntent: CustomIntentModel? = null + private val SIMPRINTS_IDENTIFICATION_EXTRA_NAMES = listOf("identification", "identifications") + private const val SIMPRINTS_GUID_KEY = "guid" } override fun createIntent( @@ -26,8 +30,7 @@ class CustomIntentActivityResultContract : ActivityResultContract? val customIntentResponseParsedData = - customIntent?.let { - mapIntentResponseData(it.customIntentResponse, intent) - } + mapIntentResponseData(resultContext.responseData, intent) if (customIntentResponseParsedData.isNullOrEmpty()) { - CustomIntentResult.Error(fieldUid = fieldUid) + mapPossibleSimprintsDuplicateResult(intent, resultContext) + ?: CustomIntentResult.Error(fieldUid = resultContext.fieldUid) } else { - returnedValues = mutableStateListOf(*customIntentResponseParsedData.toTypedArray()) - CustomIntentResult.Success( - fieldUid = fieldUid, - value = returnedValues.joinToString(separator = ","), + fieldUid = resultContext.fieldUid, + value = customIntentResponseParsedData.joinToString(separator = ","), + action = resultContext.action, + extras = intent?.extras, ) } } else { - CustomIntentResult.Error(fieldUid = fieldUid) + CustomIntentResult.Error(fieldUid = resultContext.fieldUid) } + private fun mapPossibleSimprintsDuplicateResult( + intent: Intent?, + resultContext: CustomIntentResultContext, + ): CustomIntentResult.PossibleDuplicates? { + if ( + !resultContext.isSimprintsCallout || + resultContext.isSimprintsIdentifyCallout + ) { + return null + } + + val guidValues = + SIMPRINTS_IDENTIFICATION_EXTRA_NAMES.firstNotNullOfOrNull { extraName -> + mapIntentResponseData( + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = extraName, + extraType = CustomIntentResponseExtraType.LIST_OF_OBJECTS, + key = SIMPRINTS_GUID_KEY, + ), + ), + intent = intent, + ) + } ?: return null + + return CustomIntentResult.PossibleDuplicates( + fieldUid = resultContext.fieldUid, + guidValues = guidValues, + extras = intent?.extras, + ) + } + fun mapIntentData( packageName: String, requestParameters: List, @@ -173,7 +240,7 @@ class CustomIntentActivityResultContract : ActivityResultContract, + val isSimprintsCallout: Boolean, + val isSimprintsIdentifyCallout: Boolean, +) { + data class SavedState( + val fieldUid: String, + val action: String, + val responseDataJson: String, + val isSimprintsCallout: Boolean, + val isSimprintsIdentifyCallout: Boolean, + ) + + fun toSavedState(): SavedState = + SavedState( + fieldUid = fieldUid, + action = action, + responseDataJson = Gson().toJson(responseData), + isSimprintsCallout = isSimprintsCallout, + isSimprintsIdentifyCallout = isSimprintsIdentifyCallout, + ) + + fun saveTo( + outState: Bundle, + keyPrefix: String, + ) { + with(toSavedState()){ + outState.putString("$keyPrefix.fieldUid", fieldUid) + outState.putString("$keyPrefix.action", action) + outState.putString("$keyPrefix.responseData", responseDataJson) + outState.putBoolean("$keyPrefix.isSimprintsCallout", isSimprintsCallout) + outState.putBoolean("$keyPrefix.isSimprintsIdentifyCallout", isSimprintsIdentifyCallout) + } + } + + companion object { + fun from(input: CustomIntentInput): CustomIntentResultContext = + CustomIntentResultContext( + fieldUid = input.fieldUid, + action = input.customIntent.packageName, + responseData = input.customIntent.customIntentResponse, + isSimprintsCallout = SimprintsIntentUtils.isCallout(input.customIntent), + isSimprintsIdentifyCallout = SimprintsIntentUtils.isIdentifyCallout(input.customIntent), + ) + + fun restoreFrom( + savedState: Bundle?, + keyPrefix: String, + ): CustomIntentResultContext? { + val fieldUid = + savedState?.getString("$keyPrefix.fieldUid")?.takeIf { it.isNotBlank() } + ?: return null + val action = + savedState.getString("$keyPrefix.action")?.takeIf { it.isNotBlank() } + ?: return null + val responseDataJson = + savedState.getString("$keyPrefix.responseData") + ?: return null + + return restoreFrom( + SavedState( + fieldUid = fieldUid, + action = action, + responseDataJson = responseDataJson, + isSimprintsCallout = savedState.getBoolean("$keyPrefix.isSimprintsCallout"), + isSimprintsIdentifyCallout = + savedState.getBoolean("$keyPrefix.isSimprintsIdentifyCallout"), + ), + ) + } + + fun restoreFrom(savedState: SavedState?): CustomIntentResultContext? { + val fieldUid = + savedState?.fieldUid?.takeIf { it.isNotBlank() } + ?: return null + val action = + savedState.action.takeIf { it.isNotBlank() } + ?: return null + val responseData = + parseResponseData(savedState.responseDataJson) + ?: return null + + return CustomIntentResultContext( + fieldUid = fieldUid, + action = action, + responseData = responseData, + isSimprintsCallout = savedState.isSimprintsCallout, + isSimprintsIdentifyCallout = savedState.isSimprintsIdentifyCallout, + ) + } + + private fun parseResponseData(json: String): List? = + try { + Gson().fromJson>( + json, + object : TypeToken>() {}.type, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to restore custom intent response data") + null + } + } +} + sealed class CustomIntentResult { data class Success( val fieldUid: String, val value: String, + val action: String?, + val extras: Bundle?, ) : CustomIntentResult() data class Error( val fieldUid: String, ) : CustomIntentResult() + + data class PossibleDuplicates( + val fieldUid: String, + val guidValues: List, + val extras: Bundle?, + ) : CustomIntentResult() } diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt index a2d3ea03702..fa534c50f1b 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt @@ -1,11 +1,11 @@ package org.dhis2.form.ui.provider.inputfield -import android.app.Activity.RESULT_OK import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -13,6 +13,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import org.dhis2.commons.resources.ResourceManager import org.dhis2.form.R import org.dhis2.form.di.Injector @@ -79,10 +82,26 @@ fun ProvideCustomIntentInput( resources = resources, sessionRepository = simprintsSessionRepository, ) + + val lifecycleOwner = LocalLifecycleOwner.current + var simprintsResumeCounter by remember { mutableIntStateOf(0) } + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + simprintsResumeCounter++ + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } LaunchedEffect( fieldUiModel.value, fieldUiModel.isLoadingData, simprintsCustomIntentFormPresenter.hasPendingValue, + simprintsResumeCounter, ) { val displayValues = simprintsCustomIntentFormPresenter.displayValues() if (values != displayValues) { @@ -91,32 +110,9 @@ fun ProvideCustomIntentInput( } customIntentState = getCustomIntentState(values, fieldUiModel.isLoadingData) } - val simprintsLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - val returnedValue = - simprintsCustomIntentFormPresenter.handleResult(result.resultCode, result.data) - - if (result.resultCode != RESULT_OK || returnedValue == null) { - customIntentState = CustomIntentState.LAUNCH - inputShellState = InputShellState.ERROR - if (!supportingTextList.contains(errorGettingDataMessage)) { - supportingTextList.add( - errorGettingDataMessage, - ) - } - } else { - customIntentState = CustomIntentState.LOADED - intentHandler( - FormIntent.OnSave( - fieldUiModel.uid, - returnedValue, - fieldUiModel.valueType, - ), - ) - } - } + val customIntentActivityResultContract = remember { CustomIntentActivityResultContract() } val launcher = - rememberLauncherForActivityResult(contract = CustomIntentActivityResultContract()) { + rememberLauncherForActivityResult(contract = customIntentActivityResultContract) { when (it) { is CustomIntentResult.Error -> { customIntentState = CustomIntentState.LAUNCH @@ -137,6 +133,15 @@ fun ProvideCustomIntentInput( ), ) } + is CustomIntentResult.PossibleDuplicates -> { + customIntentState = CustomIntentState.LAUNCH + inputShellState = InputShellState.ERROR + if (!supportingTextList.contains(errorGettingDataMessage)) { + supportingTextList.add( + errorGettingDataMessage, + ) + } + } } } InputCustomIntent( @@ -148,18 +153,19 @@ fun ProvideCustomIntentInput( modifier = modifier, isRequired = fieldUiModel.mandatory, onLaunch = { - if (simprintsCustomIntentFormPresenter.hasPendingValue) { - return@InputCustomIntent - } simprintsCustomIntentFormPresenter.prepareLaunch() - if (!reEvaluateRequestParams && simprintsCustomIntentFormPresenter.handlesLaunch) { + if (simprintsCustomIntentFormPresenter.handlesLaunch) { customIntentState = CustomIntentState.LOADING if (supportingTextList.contains(errorGettingDataMessage)) { supportingTextList.remove(errorGettingDataMessage) } - simprintsCustomIntentFormPresenter.createLaunchIntent() - ?.let(simprintsLauncher::launch) + uiEventHandler.invoke( + RecyclerViewUiEvents.LaunchCustomIntent( + fieldUiModel.customIntent, + fieldUiModel.uid, + ), + ) return@InputCustomIntent } diff --git a/form/src/main/res/values/strings.xml b/form/src/main/res/values/strings.xml index 5b98873d441..7170b91146b 100644 --- a/form/src/main/res/values/strings.xml +++ b/form/src/main/res/values/strings.xml @@ -92,7 +92,8 @@ Storage permission is not granted.\nYou need to enable it to use this feature. Select an app to launch intent Couldn\'t retrieve data - (From last biometric search) + (From last biometric search) + (Using last biometrics) Launch - \ No newline at end of file + diff --git a/form/src/test/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenterTest.kt b/form/src/test/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenterTest.kt index 90a839f8a1a..404ac4ad88a 100644 --- a/form/src/test/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenterTest.kt +++ b/form/src/test/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenterTest.kt @@ -111,6 +111,24 @@ class SimprintsCustomIntentFormPresenterTest { verify(sessionRepository).clear() } + @Test + fun `prepareLaunch should clear session when pending value exists and presenter handles launch`() { + val presenter = + SimprintsCustomIntentFormPresenter( + fieldValue = null, + callout = preparedCallout(responseData()), + capturesSessionId = false, + sessionRepository = sessionRepository, + contract = contract, + placeholderValue = "From last biometric search", + hasPendingValue = true, + ) + + presenter.prepareLaunch() + + verify(sessionRepository).clear() + } + @Test fun `clearPendingValue should clear session when pending value exists`() { val presenter = diff --git a/form/src/test/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContractTest.kt b/form/src/test/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContractTest.kt index ec6153e26b5..5ff488dd736 100644 --- a/form/src/test/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContractTest.kt +++ b/form/src/test/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContractTest.kt @@ -1,14 +1,17 @@ package org.dhis2.form.ui.customintent import android.content.Intent +import android.os.Bundle import com.google.gson.Gson import com.google.gson.JsonObject +import org.dhis2.mobile.commons.model.CustomIntentModel import org.dhis2.mobile.commons.model.CustomIntentRequestArgumentModel import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.kotlin.doReturn @@ -472,4 +475,145 @@ class CustomIntentActivityResultContractTest { assertEquals(3, result?.size) assertEquals(listOf("test", "42", "123"), result) } + + @Test + fun `parseResult should return possible duplicates for Simprints identifications`() { + val resultContext = + resultContext( + fieldUid = "field-uid", + customIntent = simprintsRegisterIntent(), + ) + val bundleExtras = + Bundle().apply { + putString("sessionId", "session-id") + putString("identifications", """[{"guid":"g1"},{"guid":"g2"}]""") + } + val intent = + mock { + on { hasExtra("guid") } doReturn false + on { hasExtra("identification") } doReturn false + on { hasExtra("identifications") } doReturn true + on { getStringExtra("identifications") } doReturn """[{"guid":"g1"},{"guid":"g2"}]""" + on { extras } doReturn bundleExtras + } + + val result = + contract.parseResult( + resultCode = android.app.Activity.RESULT_OK, + intent = intent, + resultContext = resultContext, + ) + + assertTrue(result is CustomIntentResult.PossibleDuplicates) + result as CustomIntentResult.PossibleDuplicates + assertEquals("field-uid", result.fieldUid) + assertEquals(listOf("g1", "g2"), result.guidValues) + } + + @Test + fun `parseResult should not treat Simprints identify result as possible duplicates`() { + val resultContext = + resultContext( + fieldUid = "field-uid", + customIntent = simprintsIdentifyIntent(), + ) + val intent = + Intent().apply { + putExtra("identifications", """[{"guid":"g1"}]""") + } + + val result = + contract.parseResult( + resultCode = android.app.Activity.RESULT_OK, + intent = intent, + resultContext = resultContext, + ) + + assertTrue(result is CustomIntentResult.Error) + } + + @Test + fun `parseResult should use restored result context after process recreation`() { + val resultContext = + resultContext( + fieldUid = "field-uid", + customIntent = simprintsRegisterIntent(), + ) + val restoredResultContext = CustomIntentResultContext.restoreFrom(resultContext.toSavedState()) + val intent = + mock { + on { hasExtra("guid") } doReturn true + on { getStringExtra("guid") } doReturn "guid-1" + } + + val result = + CustomIntentActivityResultContract().parseResult( + resultCode = android.app.Activity.RESULT_OK, + intent = intent, + resultContext = restoredResultContext, + ) + + assertTrue(result is CustomIntentResult.Success) + result as CustomIntentResult.Success + assertEquals("field-uid", result.fieldUid) + assertEquals("guid-1", result.value) + assertEquals("com.simprints.id.REGISTER", result.action) + } + + @Test + fun `parseResult should return error when result context is missing`() { + val result = + contract.parseResult( + resultCode = android.app.Activity.RESULT_OK, + intent = Intent(), + resultContext = null, + ) + + assertTrue(result is CustomIntentResult.Error) + assertEquals("", (result as CustomIntentResult.Error).fieldUid) + } + + @Test + fun `result context should restore saved response data`() { + val resultContext = + resultContext( + fieldUid = "field-uid", + customIntent = simprintsRegisterIntent(), + ) + + val restoredResultContext = CustomIntentResultContext.restoreFrom(resultContext.toSavedState()) + + assertEquals(resultContext, restoredResultContext) + } + + private fun simprintsRegisterIntent() = simprintsIntent("com.simprints.id.REGISTER") + + private fun simprintsIdentifyIntent() = simprintsIntent("com.simprints.id.IDENTIFY") + + private fun simprintsIntent(packageName: String) = + CustomIntentModel( + uid = packageName, + name = packageName, + packageName = packageName, + customIntentRequest = emptyList(), + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ), + ) + + private fun resultContext( + fieldUid: String, + customIntent: CustomIntentModel, + ) = CustomIntentResultContext.from( + CustomIntentInput( + fieldUid = fieldUid, + customIntent = customIntent, + defaultTitle = "default-title", + ), + ) }