From b0de220ee4177136d962d7e6454cde7f57f9fbc6 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 19 Apr 2026 20:25:39 +0100 Subject: [PATCH 01/16] Simprints search streamlined: MFID auto-navigation --- ...eSingleBiometricSearchNavigationUseCase.kt | 68 +++++++ .../searchTrackEntity/ui/BackdropManager.kt | 12 +- ...gleBiometricSearchNavigationUseCaseTest.kt | 188 ++++++++++++++++++ ...asAutoOpenEligibleIdentificationUseCase.kt | 63 ++++++ ...toOpenEligibleIdentificationUseCaseTest.kt | 50 +++++ 5 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt create mode 100644 app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCase.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCaseTest.kt 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..fd012a0fd62 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt @@ -0,0 +1,68 @@ +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?, + ) + + 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(), + ) + } +} 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/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt new file mode 100644 index 00000000000..ab2c939b4bd --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt @@ -0,0 +1,188 @@ +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", + ), + 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, + ), + 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/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..d23460c571c --- /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("Failed to parse JSON element in Simprints identification response: $jsonString", e) + 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/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)) + } +} From ad592ef66f70a9f127c07b90b4c720f26e9f9287 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 19 Apr 2026 20:27:44 +0100 Subject: [PATCH 02/16] Simprints search streamlined: non-essential search steps eliminated; sequential search --- ...printsLoadBiometricSearchResultsUseCase.kt | 61 +++ .../simprints/SimprintsSearchViewModel.kt | 166 +++++++- .../di/SimprintsSearchViewModelFactory.kt | 3 + .../searchTrackEntity/SearchTEActivity.kt | 12 + .../searchTrackEntity/SearchTEIViewModel.kt | 165 +++++-- .../searchTrackEntity/SearchTEModule.java | 44 +- .../SearchTeiViewModelFactory.kt | 6 +- .../listView/SearchTEList.kt | 9 + .../SearchParametersScreen.kt | 26 ++ .../provider/ParameterSelectorItemProvider.kt | 57 +++ .../ui/SearchScreenConfigurator.kt | 6 +- .../searchTrackEntity/ui/SearchTEUi.kt | 83 +++- app/src/main/res/values/strings.xml | 3 + ...tsLoadBiometricSearchResultsUseCaseTest.kt | 124 ++++++ .../simprints/SimprintsSearchViewModelTest.kt | 376 +++++++++++++++- .../SearchTEIViewModelTest.kt | 402 ++++++++++++++++-- 16 files changed, 1426 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCase.kt create mode 100644 app/src/test/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCaseTest.kt 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/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index 9e1a97edd08..089faafb721 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -2,18 +2,32 @@ 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?, @@ -31,13 +45,23 @@ class SimprintsSearchViewModel( } private val pendingDashboardNavigation = AtomicReference(null) + private var pendingSimprintsMfidBiometricIdentification: PendingSimprintsMfidBiometricIdentification? = + null + 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?, ): DashboardAction { + val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) val sessionId = sessionRepository.get()?.takeIf { searchState.hasBiometricIdentificationQuery } @@ -66,9 +90,10 @@ class SimprintsSearchViewModel( } fun prepareEnrollmentQueryData( - searchFields: List, + searchItems: List, queryData: Map?>, ): HashMap> { + val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) if (searchState.hasBiometricIdentificationQuery && sessionRepository.hasPendingSession()) { @@ -89,18 +114,147 @@ class SimprintsSearchViewModel( pendingDashboardNavigation.store(null) } - fun clearPendingSessionIfNeeded(searchFields: List) { + fun clearPendingSession() { + pendingDashboardNavigation.store(null) + pendingSimprintsMfidBiometricIdentification = null + sessionRepository.clear() + } + + fun clearPendingSessionIfNeeded(searchItems: List) { + val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) if (sessionRepository.hasPendingSession() && searchState.shouldClearPendingSession) { + pendingSimprintsMfidBiometricIdentification = null 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(), + ), + ) + } + + fun onSimprintsBiometricIdentificationResult( + uid: String, + value: String?, + hasAutoOpenEligibleSimprintsIdentification: Boolean, + ) { + pendingSimprintsMfidBiometricIdentification = + 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 (!shouldAutoNavigateToSimprintsBiometricSearch(uid, value, searchFields)) { + return null + } + + if (consumePendingSimprintsMfidBiometricIdentification(uid, value)) { + return resolveSingleBiometricSearchNavigation( + initialProgramUid = initialProgramUid, + queryData = queryData, + value = value, + )?.let { navigationTarget -> + PendingDashboardNavigation( + teiUid = navigationTarget.teiUid, + programUid = navigationTarget.programUid, + enrollmentUid = navigationTarget.enrollmentUid, + ) + }?.also { + clearPendingSession() + } ?: requestSimprintsBiometricSearchNavigation() + } + + 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) + clearPendingSession() + + return searchItems.map { field -> + if (field.uid in simprintsBiometricFieldUids) { + (field as FieldUiModelImpl).copy(value = null, displayName = null) + } else { + field + } + } + } + + fun shouldUseLastBiometricsLabel(searchItems: List): Boolean { + val searchState = SimprintsSearchUtils.searchState(searchItems.toSearchFields()) return SimprintsSearchUtils.shouldUseLastBiometricsLabel( searchState = searchState, hasPendingSession = 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 = + pendingSimprintsMfidBiometricIdentification + ?.takeIf { it.uid == uid && it.value == value } + ?.let { + pendingSimprintsMfidBiometricIdentification = null + true + } ?: false + + 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/searchTrackEntity/SearchTEActivity.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt index 67cdbb9410c..d95978d42db 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -211,6 +211,7 @@ class SearchTEActivity : observeScreenState() observeDownload() observeLegacyInteractions() + observeSimprintsBiometricSearchNavigation() observeSimprintsNavigation() if (intent.shouldLaunchSyncDialog()) { @@ -601,6 +602,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) { 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..b259f127c34 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,6 @@ 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.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel import org.dhis2.form.model.FieldUiModel @@ -59,6 +56,7 @@ 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.SimprintsSearchViewModel import org.dhis2.usescases.searchTrackEntity.listView.SearchResult import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState @@ -104,7 +102,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 +140,12 @@ 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 var searching: Boolean = false private val filtersActive = MutableLiveData(false) @@ -448,10 +450,37 @@ class SearchTEIViewModel( fun clearQueryData() { 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 onSimprintsBiometricSearchNavigation() { + onNavigationPageChanged(NavigationPage.LIST_VIEW) + setListScreen() + searchRepository.clearFetchedList() + performSearch() + } + private fun clearSearchParameters() { val updatedItems = searchParametersUiState.items.map { @@ -460,6 +489,7 @@ class SearchTEIViewModel( searchParametersUiState = searchParametersUiState.copy( items = updatedItems, + clearSearchEnabled = false, searchedItems = mapOf(), ) searching = false @@ -604,29 +634,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() { @@ -736,7 +751,7 @@ class SearchTEIViewModel( fun prepareEnrollmentQueryData(queryData: Map?>): HashMap> = simprintsSearchViewModel.prepareEnrollmentQueryData( - searchFields = getSimprintsSearchFields(), + searchItems = searchParametersUiState.items, queryData = queryData, ) @@ -750,7 +765,7 @@ class SearchTEIViewModel( when ( val action = simprintsSearchViewModel.onDashboardRequested( - searchFields = getSimprintsSearchFields(), + searchItems = searchParametersUiState.items, teiUid = teiUid, programUid = programUid, enrollmentUid = enrollmentUid, @@ -803,22 +818,21 @@ 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, + ) { + 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 refreshSimprintsUiState() { + simprintsSearchViewModel.refreshSimprintsUiState(searchParametersUiState.items) + } fun onEnrollClick() { _legacyInteraction.postValue(LegacyInteraction.OnEnrollClick(queryData)) @@ -1128,14 +1142,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 +1215,27 @@ 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() + _simprintsNavigation.send( + SimprintsNavigationAction.OpenDashboard( + teiUid = navigation.teiUid, + programUid = navigation.programUid, + enrollmentUid = navigation.enrollmentUid, + ), + ) + } + } } is FormIntent.OnQrCodeScanned -> { 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..c30906e5cd7 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 @@ -229,6 +229,9 @@ class SearchTEList : FragmentGlobalAbstract() { remember(viewModel.searchParametersUiState) { viewModel.searchParametersUiState.searchedItems } + val isSimprintsBiometricSearch by viewModel + .isSimprintsBiometricSearch + .observeAsState(false) FullSearchButtonAndWorkingList( teTypeName = teTypeName!!, @@ -244,6 +247,12 @@ class SearchTEList : FragmentGlobalAbstract() { viewModel.clearQueryData() viewModel.clearFocus() }, + isSimprintsBiometricSearch = isSimprintsBiometricSearch, + onSimprintsBiometricSearchFallbackClick = { + viewModel.clearSimprintsBiometricQueryData() + viewModel.clearFocus() + viewModel.setSearchScreen() + }, workingListViewModel = workingListViewModel, ) } 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..37fa1b40be0 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 @@ -22,10 +22,14 @@ 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -70,6 +74,7 @@ fun SearchParametersScreen( resourceManager: ResourceManager, uiState: SearchParametersUiState, intentHandler: (FormIntent) -> Unit, + onSimprintsBiometricIdentificationResult: (String, String?, Boolean) -> Unit, onShowOrgUnit: ( uid: String, preselectedOrgUnits: List, @@ -84,6 +89,7 @@ fun SearchParametersScreen( val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current val configuration = LocalConfiguration.current + var showSimprintsBiometricNoMatchesMessage by remember { mutableStateOf(false) } val scanContract = remember { ScanContract() } val qrScanLauncher = @@ -162,6 +168,7 @@ fun SearchParametersScreen( LaunchedEffect(uiState.isOnBackPressed) { uiState.isOnBackPressed.collectLatest { if (it) { + showSimprintsBiometricNoMatchesMessage = false focusManager.clearFocus() onClose() } @@ -256,6 +263,10 @@ fun SearchParametersScreen( focusManager = focusManager, fieldUiModel = fieldUiModel, callback = callback, + onSimprintsBiometricIdentificationResult = onSimprintsBiometricIdentificationResult, + onSimprintsBiometricSearchNoMatchesChanged = { + showSimprintsBiometricNoMatchesMessage = it + }, onNextClicked = { val nextIndex = index + 1 if (nextIndex < uiState.items.size) { @@ -265,6 +276,16 @@ fun SearchParametersScreen( ), ) } + + if (showSimprintsBiometricNoMatchesMessage) { + item { + Text( + text = resourceManager.getString(R.string.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 +307,7 @@ fun SearchParametersScreen( }, ) { focusManager.clearFocus() + showSimprintsBiometricNoMatchesMessage = false onClear() } } @@ -317,6 +339,7 @@ fun SearchParametersScreen( ) }, ) { + showSimprintsBiometricNoMatchesMessage = false focusManager.clearFocus() onSearch() } @@ -347,6 +370,7 @@ fun SearchFormPreview() { }, ), intentHandler = {}, + onSimprintsBiometricIdentificationResult = { _, _, _ -> }, onShowOrgUnit = { _, _, _, _ -> }, onSearch = {}, onClear = {}, @@ -378,6 +402,7 @@ fun SearchFormPreviewWithClear() { }, ), intentHandler = {}, + onSimprintsBiometricIdentificationResult = { _, _, _ -> }, onShowOrgUnit = { _, _, _, _ -> }, onSearch = {}, onClear = {}, @@ -409,6 +434,7 @@ fun initSearchScreen( uiState = viewModel.searchParametersUiState, onSearch = viewModel::onSearch, intentHandler = viewModel::onParameterIntent, + onSimprintsBiometricIdentificationResult = viewModel::onSimprintsBiometricIdentificationResult, onShowOrgUnit = onShowOrgUnit, onClear = { onClear() 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..d3aeb4b130d 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,8 @@ package org.dhis2.usescases.searchTrackEntity.searchparameters.provider +import android.app.Activity.RESULT_OK +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material.icons.outlined.QrCode2 @@ -11,11 +14,17 @@ 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 org.dhis2.R import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase +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.intent.FormIntent import org.dhis2.form.ui.provider.inputfield.FieldProvider import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle @@ -30,8 +39,46 @@ fun provideParameterSelectorItem( fieldUiModel: FieldUiModel, callback: FieldUiModel.Callback, onNextClicked: () -> Unit, + onSimprintsBiometricIdentificationResult: (String, String?, Boolean) -> Unit, + onSimprintsBiometricSearchNoMatchesChanged: (Boolean) -> 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 hasAutoOpenEligibleSimprintsIdentification = + remember { SimprintsHasAutoOpenEligibleIdentificationUseCase() } + val simprintsIdentifyLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val returnedValue = + simprintsCustomIntentFormPresenter.handleResult(result.resultCode, result.data) + + if (result.resultCode == RESULT_OK && returnedValue != null) { + onSimprintsBiometricSearchNoMatchesChanged(false) + onSimprintsBiometricIdentificationResult( + fieldUiModel.uid, + returnedValue, + hasAutoOpenEligibleSimprintsIdentification(result.data?.extras), + ) + callback.intent( + FormIntent.OnSave( + uid = fieldUiModel.uid, + value = returnedValue, + valueType = fieldUiModel.valueType, + ), + ) + } else { + onSimprintsBiometricSearchNoMatchesChanged(result.resultCode == RESULT_OK) + } + } val status = if (fieldUiModel.focused) { @@ -72,6 +119,16 @@ fun provideParameterSelectorItem( }, status = status, onExpand = { + if (SimprintsIntentUtils.isIdentifyCallout(fieldUiModel.customIntent)) { + if (!simprintsCustomIntentFormPresenter.hasPendingValue) { + onSimprintsBiometricSearchNoMatchesChanged(false) + simprintsCustomIntentFormPresenter.prepareLaunch() + simprintsCustomIntentFormPresenter + .createLaunchIntent() + ?.let(simprintsIdentifyLauncher::launch) + } + return@ParameterSelectorItemModel + } performOnExpandActions(fieldUiModel, callback) }, ) 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..258e9780566 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,12 +188,14 @@ fun AddNewButton( fun SearchButtonWithQuery( modifier: Modifier = Modifier, queryData: Map = emptyMap(), + simprintsQueryTextOverride: String? = null, + isSimprintsBiometricSearch: Boolean = false, onClick: () -> Unit, onClearSearchQuery: () -> Unit, ) { Box(modifier) { SearchBar( - text = queryData.values.joinToString(separator = ", "), + text = simprintsQueryTextOverride ?: queryData.values.joinToString(separator = ", "), modifier = Modifier.fillMaxWidth(), onQueryChange = { if (it.isBlank()) onClearSearchQuery() @@ -207,15 +209,22 @@ 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 +254,8 @@ fun FullSearchButtonAndWorkingList( onEnrollClick: () -> Unit = {}, onCloseFilters: () -> Unit = {}, onClearSearchQuery: () -> Unit = {}, + isSimprintsBiometricSearch: Boolean = false, + onSimprintsBiometricSearchFallbackClick: () -> Unit = {}, workingListViewModel: WorkingListViewModel? = null, ) { Column(modifier = modifier) { @@ -270,6 +281,13 @@ fun FullSearchButtonAndWorkingList( SearchButtonWithQuery( modifier = Modifier.fillMaxWidth(), queryData = queryData, + simprintsQueryTextOverride = + if (isSimprintsBiometricSearch) { + stringResource(R.string.biometric_search) + } else { + null + }, + isSimprintsBiometricSearch = isSimprintsBiometricSearch, onClick = onSearchClick, onClearSearchQuery = onClearSearchQuery, ) @@ -309,6 +327,18 @@ fun FullSearchButtonAndWorkingList( } } } + + if (isSimprintsBiometricSearch && queryData.isNotEmpty()) { + SimprintsBiometricSearchFallbackButton( + modifier = + Modifier.padding( + top = Spacing.Spacing8, + start = Spacing.Spacing16, + end = Spacing.Spacing16, + ), + onClick = onSimprintsBiometricSearchFallbackClick, + ) + } } Spacer(modifier = Modifier.requiredHeight(Spacing.Spacing16)) @@ -319,6 +349,39 @@ fun FullSearchButtonAndWorkingList( } } +@Composable +private 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.biometric_search_fallback_action), + style = getTextStyle(style = DHIS2TextStyle.LABEL_LARGE), + ) + } +} + @Composable private fun SearchAndCreateTEIButton( onSearchClick: () -> Unit, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a39fdfb0e6..057d0b149e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,9 @@ Search Search / Add new %s Search for %s + Biometric search + No biometric match?\nSearch by name instead + No biometric search matches. Try searching by name, etc. Add new %s Enter text Enter long text 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/SimprintsSearchViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt index 39e372dcdd7..fefe562265c 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt @@ -2,19 +2,23 @@ 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 @@ -24,9 +28,14 @@ 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`() = @@ -42,11 +51,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", @@ -79,18 +89,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", @@ -110,11 +121,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", @@ -131,23 +143,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,15 +171,16 @@ class SimprintsSearchViewModelTest { SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) val filteredQueryData = viewModel.prepareEnrollmentQueryData( - searchFields = listOf(textField(uid = "name", value = "Alice")), - queryData = mapOf("name" to listOf("Alice")), + searchItems = listOf(textField(uid = "name", value = "Name")), + queryData = mapOf("name" to listOf("Name")), ) - assertEquals(hashMapOf("name" to listOf("Alice")), filteredQueryData) + assertEquals(hashMapOf("name" to listOf("Name")), filteredQueryData) verify(sessionRepository, never()).markPendingEnrollment() } @@ -177,10 +191,11 @@ 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() @@ -193,11 +208,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 +227,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) @@ -234,10 +251,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 +265,341 @@ 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())) 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", + ) + 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", + ) + 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", + ) + 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 `clearSimprintsBiometricQueryData should clear only biometric search items`() { + 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) + verify(sessionRepository).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 `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/searchTrackEntity/SearchTEIViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt index 011d95def48..f5bbd30d80f 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,11 +37,13 @@ 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 @@ -54,12 +57,13 @@ 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.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import kotlin.text.get @OptIn(ExperimentalCoroutinesApi::class) class SearchTEIViewModelTest { @@ -82,7 +86,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 +103,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 +130,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, - orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, + loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) testingDispatcher.scheduler.advanceUntilIdle() } @@ -336,20 +349,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 +375,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 +396,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 +425,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() @@ -542,6 +565,34 @@ class SearchTEIViewModelTest { } } + @Test + fun `Should emit open dashboard navigation when Simprints dashboard request resolves directly`() = + runTest { + whenever( + simprintsSearchViewModel.onDashboardRequested(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 { @@ -587,15 +638,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 +742,222 @@ 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", + ) + 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.simprintsNavigation.test { + viewModel.onParameterIntent( + FormIntent.OnSave( + uid = "biometric", + value = "guid-1", + valueType = ValueType.TEXT, + ), + ) + testingDispatcher.scheduler.advanceUntilIdle() + + val action = awaitItem() + assertTrue(action is SimprintsNavigationAction.OpenDashboard) + action as SimprintsNavigationAction.OpenDashboard + assertEquals("teiUid", action.teiUid) + assertEquals("matchedProgramUid", action.programUid) + assertEquals("enrollmentUid", action.enrollmentUid) + 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()) + assertEquals(null, viewModel.searchParametersUiState.items.single().value) + assertEquals(null, viewModel.searchParametersUiState.items.single().displayName) + cancelAndIgnoreRemainingEvents() + } + } + + @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 +1280,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 +1331,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, - orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, + loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -1039,7 +1379,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, - orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, + loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -1191,10 +1531,18 @@ 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, From f962a63cb93dec4c1db6664fde8080233e569a6b Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Apr 2026 11:28:52 +0100 Subject: [PATCH 03/16] Simprints Enrolment+ & sequential search UI components extracted --- .../ui/SimprintsSearchTeUiComponents.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SimprintsSearchTeUiComponents.kt 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, + ) +} + From b24419485491939838d2d8e451731dd08faf824a Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Apr 2026 11:33:43 +0100 Subject: [PATCH 04/16] Simprints Enrolment+: usecases --- ...dPossibleDuplicatesSearchResultsUseCase.kt | 64 ++++++++++ ...sibleDuplicatesSearchResultsUseCaseTest.kt | 112 ++++++++++++++++++ ...intsExtractIdentificationMatchesUseCase.kt | 74 ++++++++++++ ...sResolvePossibleDuplicatesSearchUseCase.kt | 39 ++++++ ...ExtractIdentificationMatchesUseCaseTest.kt | 40 +++++++ ...olvePossibleDuplicatesSearchUseCaseTest.kt | 83 +++++++++++++ 6 files changed, 412 insertions(+) create mode 100644 app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt create mode 100644 app/src/test/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCase.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePossibleDuplicatesSearchUseCase.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCaseTest.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePossibleDuplicatesSearchUseCaseTest.kt 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/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/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..ff655bf48ed --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCase.kt @@ -0,0 +1,74 @@ +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: $jsonString") + 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 -> + runCatching { value.asFloat }.getOrNull() + } + + 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/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/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/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) + } +} From ce076f62e83797980e763a2a67c82a7e8e486b8e Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Apr 2026 12:21:28 +0100 Subject: [PATCH 05/16] Simprints Enrolment+: integration into existing code --- .../simprints/SimprintsEnrollmentViewModel.kt | 22 +++-- .../simprints/SimprintsSearchViewModel.kt | 12 ++- .../enrollment/EnrollmentActivity.kt | 91 ++++++++++++++++++- .../enrollment/EnrollmentPresenterImpl.kt | 11 ++- .../usescases/enrollment/FormInjector.kt | 9 ++ .../searchTrackEntity/SearchTEActivity.kt | 43 +++++++++ .../searchTrackEntity/SearchTEIViewModel.kt | 82 +++++++++++++++-- .../listView/SearchTEList.kt | 28 +++++- .../SearchParametersScreen.kt | 2 +- .../searchTrackEntity/ui/SearchTEUi.kt | 51 ++--------- app/src/main/res/values/strings.xml | 8 +- .../SimprintsEnrollmentViewModelTest.kt | 74 +++++++++++++-- .../simprints/SimprintsSearchViewModelTest.kt | 30 ++++++ .../enrollment/EnrollmentPresenterImplTest.kt | 21 ++++- .../SearchTEIViewModelTest.kt | 88 +++++++++++++++++- .../repository/SimprintsSessionRepository.kt | 26 +++++- .../SimprintsSessionRepositoryTest.kt | 49 ++++++++++ ...imprintsPossibleDuplicatesSearchHandler.kt | 9 ++ ...printsRememberCustomIntentFormPresenter.kt | 7 +- .../main/java/org/dhis2/form/ui/FormView.kt | 25 +++-- .../dhis2/form/ui/FormViewFragmentFactory.kt | 3 + .../inputfield/CustomIntentProvider.kt | 69 ++++++++++++-- form/src/main/res/values/strings.xml | 5 +- .../SimprintsCustomIntentFormPresenterTest.kt | 18 ++++ 24 files changed, 674 insertions(+), 109 deletions(-) create mode 100644 form/src/main/java/org/dhis2/form/simprints/SimprintsPossibleDuplicatesSearchHandler.kt diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index 7b216de212f..bdc2eee09fd 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -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,6 +38,19 @@ class SimprintsEnrollmentViewModel( return resolvedAction.callout.launchIntent } + suspend fun onAutoEnrollLastRequested(enrollmentUid: String): Intent? { + sessionRepository.clearPendingEnrollment() + val sessionId = sessionRepository.get()?.takeIf(String::isNotBlank) ?: return null + val resolvedAction = + resolvePendingEnrollmentAction( + enrollmentUid = enrollmentUid, + sessionId = sessionId, + ) ?: return null + + pendingAction.store(resolvedAction) + return resolvedAction.callout.launchIntent + } + suspend fun onRegisterLastResult( resultCode: Int, data: Intent?, diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index 089faafb721..528b75d1d4a 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -60,6 +60,7 @@ class SimprintsSearchViewModel( teiUid: String, programUid: String?, enrollmentUid: String?, + keepSession: Boolean = false, ): DashboardAction { val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) @@ -85,7 +86,9 @@ class SimprintsSearchViewModel( } pendingDashboardNavigation.store(navigation) - sessionRepository.clear() + if (!keepSession) { + sessionRepository.clear() + } return DashboardAction.LaunchConfirmIdentity(confirmIdentityIntent) } @@ -106,8 +109,13 @@ class SimprintsSearchViewModel( ) } + fun markPendingEnrollmentFromSimprintsPossibleDuplicates() { + sessionRepository.markPendingEnrollmentFromPossibleDuplicates() + } + fun onConfirmIdentityResult(resultCode: Int): PendingDashboardNavigation? = - pendingDashboardNavigation.exchange(null) + pendingDashboardNavigation + .exchange(null) ?.takeIf { resultCode == RESULT_OK } fun onConfirmIdentityLaunchFailed() { 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..e902549f196 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt @@ -31,6 +31,7 @@ import org.dhis2.maps.views.MapSelectorActivity 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 @@ -89,6 +90,78 @@ class EnrollmentActivity : } } + private val simprintsAutoRegisterLastBiometricsLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + lifecycleScope.launch { + try { + when ( + presenter.onRegisterLastResult( + resultCode = result.resultCode, + data = result.data, + ) + ) { + 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" @@ -167,12 +240,28 @@ class EnrollmentActivity : ), locationProvider = locationProvider, dateEditionWarningHandler = dateEditionWarningHandler, + 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) { 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..da07e82515e 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt @@ -160,12 +160,13 @@ 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, ) 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..ea290c20c25 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/FormInjector.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/FormInjector.kt @@ -7,6 +7,7 @@ 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 @@ -26,6 +27,7 @@ fun AppCompatActivity.buildEnrollmentForm( config: EnrollmentFormBuilderConfig, locationProvider: LocationProvider, dateEditionWarningHandler: DateEditionWarningHandler, + onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)? = null, onFinish: () -> Unit, ): FormView = FormView @@ -46,6 +48,13 @@ fun AppCompatActivity.buildEnrollmentForm( ) } }.onFinishDataEntry(onFinish) + .run { + if (onLaunchSimprintsPossibleDuplicatesSearch != null) { + onLaunchSimprintsPossibleDuplicatesSearch(onLaunchSimprintsPossibleDuplicatesSearch) + } else { + this + } + } .factory(supportFragmentManager) .setRecords( EnrollmentRecords( 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 d95978d42db..44218425628 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -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 = @@ -270,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() @@ -639,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() + } } } } @@ -826,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?, @@ -843,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 b259f127c34..1d758c2f787 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -43,6 +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.utils.SimprintsIntentUtils import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel import org.dhis2.form.model.FieldUiModel @@ -57,6 +58,7 @@ 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.usescases.searchTrackEntity.listView.SearchResult import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState @@ -86,6 +88,10 @@ sealed class SimprintsNavigationAction { data class ShowMessage( val message: String, ) : SimprintsNavigationAction() + + object FinishSimprintsPossibleDuplicatesSearch : SimprintsNavigationAction() + + object AutoEnrollLastBiometricsFromSimprintsPossibleDuplicates : SimprintsNavigationAction() } class SearchTEIViewModel( @@ -146,6 +152,8 @@ class SearchTEIViewModel( 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) @@ -173,6 +181,11 @@ class SearchTEIViewModel( private val onNewSearch = MutableSharedFlow(extraBufferCapacity = 1) + private var simprintsPossibleDuplicatesAutoNoneOfAboveTriggered: Boolean = false + + private val loadSimprintsPossibleDuplicatesSearchResultsUseCase = + SimprintsLoadPossibleDuplicatesSearchResultsUseCase(searchRepository, searchRepositoryKt) + val searchPagingData = onNewSearch .onStart { emit(Unit) } @@ -206,6 +219,14 @@ class SearchTEIViewModel( refreshSimprintsUiState() } + fun clearSimprintsSession() { + simprintsSearchViewModel.clearPendingSession() + } + + fun setSimprintsPossibleDuplicatesSearch(isSimprintsPossibleDuplicatesSearch: Boolean) { + _isSimprintsPossibleDuplicatesSearch.value = isSimprintsPossibleDuplicatesSearch + } + private fun loadNavigationBarItems() { CoroutineTracker.increment() val pageConfigurator = searchNavPageConfigurator.initVariables() @@ -474,6 +495,13 @@ class SearchTEIViewModel( updateSearch() } + fun onSimprintsPossibleDuplicatesNoneOfAboveClick() { + simprintsSearchViewModel.markPendingEnrollmentFromSimprintsPossibleDuplicates() + viewModelScope.launch { + _simprintsNavigation.send(SimprintsNavigationAction.FinishSimprintsPossibleDuplicatesSearch) + } + } + fun onSimprintsBiometricSearchNavigation() { onNavigationPageChanged(NavigationPage.LIST_VIEW) setListScreen() @@ -518,14 +546,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 -> @@ -555,6 +603,12 @@ class SearchTEIViewModel( } } + private fun onSimprintsPossibleDuplicatesAutoEnrollLastClick() { + viewModelScope.launch { + _simprintsNavigation.send(SimprintsNavigationAction.AutoEnrollLastBiometricsFromSimprintsPossibleDuplicates) + } + } + private suspend fun loadDisplayInListResults() = withContext(dispatchers.io()) { val searchParametersModel = @@ -681,7 +735,9 @@ class SearchTEIViewModel( ) when (_screenState.value?.screenState) { - SearchScreenState.LIST -> { + SearchScreenState.LIST, + SearchScreenState.NONE, + null -> { setListScreen() onNewSearch.emit(Unit) } @@ -717,7 +773,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 -> @@ -769,6 +828,7 @@ class SearchTEIViewModel( teiUid = teiUid, programUid = programUid, enrollmentUid = enrollmentUid, + keepSession = _isSimprintsPossibleDuplicatesSearch.value == true, ) ) { is SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity -> { @@ -1363,6 +1423,14 @@ 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/listView/SearchTEList.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt index c30906e5cd7..a70569fd614 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 @@ -232,6 +233,9 @@ class SearchTEList : FragmentGlobalAbstract() { val isSimprintsBiometricSearch by viewModel .isSimprintsBiometricSearch .observeAsState(false) + val isSimprintsPossibleDuplicatesSearch by viewModel + .isSimprintsPossibleDuplicatesSearch + .observeAsState(false) FullSearchButtonAndWorkingList( teTypeName = teTypeName!!, @@ -244,10 +248,15 @@ class SearchTEList : FragmentGlobalAbstract() { onEnrollClick = { viewModel.onEnrollClick() }, onCloseFilters = { viewModel.onFiltersClick(isLandscape()) }, onClearSearchQuery = { - viewModel.clearQueryData() - viewModel.clearFocus() + if (isSimprintsPossibleDuplicatesSearch) { + requireActivity().finish() + } else { + viewModel.clearQueryData() + viewModel.clearFocus() + } }, isSimprintsBiometricSearch = isSimprintsBiometricSearch, + isSimprintsPossibleDuplicatesSearch = isSimprintsPossibleDuplicatesSearch, onSimprintsBiometricSearchFallbackClick = { viewModel.clearSimprintsBiometricQueryData() viewModel.clearFocus() @@ -280,6 +289,9 @@ class SearchTEList : FragmentGlobalAbstract() { val isSimprintsUseLastBiometricsLabel by viewModel .isSimprintsUseLastBiometricsLabel .observeAsState(false) + val isSimprintsPossibleDuplicatesSearch by viewModel + .isSimprintsPossibleDuplicatesSearch + .observeAsState(false) updateLayoutParams { val bottomMargin = @@ -292,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() 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 37fa1b40be0..1f939c73677 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 @@ -280,7 +280,7 @@ fun SearchParametersScreen( if (showSimprintsBiometricNoMatchesMessage) { item { Text( - text = resourceManager.getString(R.string.biometric_search_no_matches), + 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), ) 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 258e9780566..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,14 +188,13 @@ fun AddNewButton( fun SearchButtonWithQuery( modifier: Modifier = Modifier, queryData: Map = emptyMap(), - simprintsQueryTextOverride: String? = null, isSimprintsBiometricSearch: Boolean = false, onClick: () -> Unit, onClearSearchQuery: () -> Unit, ) { Box(modifier) { SearchBar( - text = simprintsQueryTextOverride ?: queryData.values.joinToString(separator = ", "), + text = queryData.values.joinToString(separator = ", "), modifier = Modifier.fillMaxWidth(), onQueryChange = { if (it.isBlank()) onClearSearchQuery() @@ -223,8 +222,7 @@ fun SearchButtonWithQuery( ), ) } - } - + }, ) } } @@ -255,6 +253,7 @@ fun FullSearchButtonAndWorkingList( onCloseFilters: () -> Unit = {}, onClearSearchQuery: () -> Unit = {}, isSimprintsBiometricSearch: Boolean = false, + isSimprintsPossibleDuplicatesSearch: Boolean = false, onSimprintsBiometricSearchFallbackClick: () -> Unit = {}, workingListViewModel: WorkingListViewModel? = null, ) { @@ -281,13 +280,8 @@ fun FullSearchButtonAndWorkingList( SearchButtonWithQuery( modifier = Modifier.fillMaxWidth(), queryData = queryData, - simprintsQueryTextOverride = - if (isSimprintsBiometricSearch) { - stringResource(R.string.biometric_search) - } else { - null - }, - isSimprintsBiometricSearch = isSimprintsBiometricSearch, + isSimprintsBiometricSearch = + isSimprintsBiometricSearch || isSimprintsPossibleDuplicatesSearch, onClick = onSearchClick, onClearSearchQuery = onClearSearchQuery, ) @@ -328,7 +322,7 @@ fun FullSearchButtonAndWorkingList( } } - if (isSimprintsBiometricSearch && queryData.isNotEmpty()) { + if (isSimprintsBiometricSearch && !isSimprintsPossibleDuplicatesSearch && queryData.isNotEmpty()) { SimprintsBiometricSearchFallbackButton( modifier = Modifier.padding( @@ -349,39 +343,6 @@ fun FullSearchButtonAndWorkingList( } } -@Composable -private 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.biometric_search_fallback_action), - style = getTextStyle(style = DHIS2TextStyle.LABEL_LARGE), - ) - } -} - @Composable private fun SearchAndCreateTEIButton( onSearchClick: () -> Unit, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 057d0b149e0..d06b6357755 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,9 +81,11 @@ Search Search / Add new %s Search for %s - Biometric search - No biometric match?\nSearch by name instead - No biometric search matches. Try searching by name, etc. + 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..32fa230dfdf 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,65 @@ class SimprintsEnrollmentViewModelTest { val preparedIntent = viewModel.onFinishRequested( - isNewEnrollment = true, + enrollmentUid = "enrollment-uid", + ) + val result = + viewModel.onRegisterLastResult( + resultCode = RESULT_OK, + data = resultIntent, + teiUid = "tei-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 = @@ -168,7 +225,6 @@ class SimprintsEnrollmentViewModelTest { ) viewModel.onFinishRequested( - isNewEnrollment = true, enrollmentUid = "enrollment-uid", ) val result = @@ -215,7 +271,6 @@ class SimprintsEnrollmentViewModelTest { ) viewModel.onFinishRequested( - isNewEnrollment = true, enrollmentUid = "enrollment-uid", ) val result = @@ -278,7 +333,6 @@ class SimprintsEnrollmentViewModelTest { ) viewModel.onFinishRequested( - isNewEnrollment = true, enrollmentUid = "enrollment-uid", ) viewModel.onRegisterLastLaunchFailed() diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt index fefe562265c..ce8724e1b54 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt @@ -76,6 +76,36 @@ 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())) 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 { 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..158ec3f9547 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,15 +30,21 @@ 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.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.anyOrNull 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() @@ -264,12 +271,11 @@ class EnrollmentPresenterImplTest { val intent: Intent = mock() whenever( simprintsEnrollmentViewModel.onFinishRequested( - isNewEnrollment = true, enrollmentUid = "enrollmentUid", ), ) doReturn intent - val result = presenter.onFinishRequested(true, "enrollmentUid") + val result = presenter.onFinishRequested("enrollmentUid") assert(result == intent) } @@ -284,14 +290,19 @@ class EnrollmentPresenterImplTest { whenever(enrollmentRepository.blockingGet()) doReturn enrollment whenever( simprintsEnrollmentViewModel.onRegisterLastResult( - resultCode = 1, - data = null, - teiUid = "teiUid", + resultCode = any(), + data = anyOrNull(), + teiUid = anyOrNull(), ), ) doReturn SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH val result = presenter.onRegisterLastResult(resultCode = 1, data = null) + verify(simprintsEnrollmentViewModel).onRegisterLastResult( + resultCode = 1, + data = null, + teiUid = "teiUid", + ) 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 f5bbd30d80f..85454690999 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -61,6 +61,7 @@ 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 @@ -439,6 +440,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`() { @@ -551,7 +633,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 { @@ -569,7 +651,7 @@ class SearchTEIViewModelTest { fun `Should emit open dashboard navigation when Simprints dashboard request resolves directly`() = runTest { whenever( - simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any()), + simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any(), any()), ) doReturn SimprintsSearchViewModel.DashboardAction.OpenDashboard( SimprintsSearchViewModel.PendingDashboardNavigation( @@ -621,7 +703,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" 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..8028fee6122 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 @@ -5,6 +5,11 @@ import org.dhis2.commons.prefs.PreferenceProvider 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 +22,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 +40,17 @@ 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 { + runCatching { PendingEnrollmentSource.valueOf(it) }.getOrNull() + } == 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 +60,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/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/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..946724cfd08 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormView.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormView.kt @@ -12,6 +12,7 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -41,6 +42,7 @@ 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.form.R import org.dhis2.form.data.RulesUtilsProviderConfigurationError import org.dhis2.form.data.scan.ScanContract @@ -52,6 +54,7 @@ import org.dhis2.form.model.InfoUiModel import org.dhis2.form.model.RowAction import org.dhis2.form.model.UiRenderType import org.dhis2.form.model.exception.RepositoryRecordsException +import org.dhis2.form.simprints.LocalSimprintsPossibleDuplicatesSearchHandler import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract import org.dhis2.form.ui.customintent.CustomIntentInput import org.dhis2.form.ui.customintent.CustomIntentResult @@ -77,6 +80,7 @@ class FormView : Fragment() { private var onFocused: (() -> Unit)? = null private var onFinishDataEntry: (() -> Unit)? = null private var onActivityForResult: (() -> 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 @@ -215,12 +219,14 @@ class FormView : Fragment() { } } - Form( - sections = sections, - intentHandler = ::intentHandler, - uiEventHandler = ::uiEventHandler, - resources = Injector.provideResourcesManager(context), - ) + CompositionLocalProvider(LocalSimprintsPossibleDuplicatesSearchHandler provides onLaunchSimprintsPossibleDuplicatesSearch) { + Form( + sections = sections, + intentHandler = ::intentHandler, + uiEventHandler = ::uiEventHandler, + resources = Injector.provideResourcesManager(context), + ) + } resultDialogData?.let { DataEntryBottomSheet( @@ -620,6 +626,7 @@ class FormView : Fragment() { onFocused: (() -> Unit)?, onFinishDataEntry: (() -> Unit)?, onActivityForResult: (() -> Unit)?, + onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)?, onFieldItemsRendered: ((fieldsEmpty: Boolean) -> Unit)?, ) { this.onItemChangeListener = onItemChangeListener @@ -627,6 +634,7 @@ class FormView : Fragment() { this.onFocused = onFocused this.onFinishDataEntry = onFinishDataEntry this.onActivityForResult = onActivityForResult + this.onLaunchSimprintsPossibleDuplicatesSearch = onLaunchSimprintsPossibleDuplicatesSearch this.onFieldItemsRendered = onFieldItemsRendered } @@ -639,6 +647,7 @@ class FormView : Fragment() { private var onFocused: (() -> Unit)? = null private var onActivityForResult: (() -> Unit)? = null private var onFinishDataEntry: (() -> 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 +682,9 @@ class FormView : Fragment() { fun onFinishDataEntry(callback: () -> Unit) = apply { this.onFinishDataEntry = 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 +708,7 @@ class FormView : Fragment() { onFocused, onFinishDataEntry, onActivityForResult, + 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..08fe8b98dd9 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewFragmentFactory.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewFragmentFactory.kt @@ -3,6 +3,7 @@ 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 class FormViewFragmentFactory( @@ -12,6 +13,7 @@ class FormViewFragmentFactory( private val onFocused: (() -> Unit)?, private val onFinishDataEntry: (() -> Unit)?, private val onActivityForResult: (() -> 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 +33,7 @@ class FormViewFragmentFactory( onFocused = onFocused, onFinishDataEntry = onFinishDataEntry, onActivityForResult = onActivityForResult, + onLaunchSimprintsPossibleDuplicatesSearch = onLaunchSimprintsPossibleDuplicatesSearch, onFieldItemsRendered = onFieldItemsRendered, ) setConfiguration( 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..f669a943048 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 @@ -4,8 +4,10 @@ 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,12 +15,19 @@ 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.commons.simprints.usecases.SimprintsExtractIdentificationMatchesUseCase +import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSearchUseCase +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.form.R import org.dhis2.form.di.Injector import org.dhis2.form.extensions.inputState import org.dhis2.form.extensions.supportingText import org.dhis2.form.model.FieldUiModel +import org.dhis2.form.simprints.LocalSimprintsPossibleDuplicatesSearchHandler import org.dhis2.form.simprints.rememberSimprintsCustomIntentFormPresenter import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract import org.dhis2.form.ui.customintent.CustomIntentInput @@ -43,6 +52,7 @@ fun ProvideCustomIntentInput( modifier: Modifier, ) { val context = LocalContext.current.applicationContext + val simprintsPossibleDuplicatesSearchHandler = LocalSimprintsPossibleDuplicatesSearchHandler.current val values = remember(fieldUiModel) { fieldUiModel.value?.takeIf { it.isNotEmpty() }?.let { value -> @@ -79,10 +89,34 @@ 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) + } + } + + val simprintsResolvePossibleDuplicatesSearchUseCase = + remember(simprintsSessionRepository) { + SimprintsResolvePossibleDuplicatesSearchUseCase( + extractIdentificationMatches = SimprintsExtractIdentificationMatchesUseCase(), + sessionRepository = simprintsSessionRepository, + ) + } LaunchedEffect( fieldUiModel.value, fieldUiModel.isLoadingData, simprintsCustomIntentFormPresenter.hasPendingValue, + simprintsResumeCounter, ) { val displayValues = simprintsCustomIntentFormPresenter.displayValues() if (values != displayValues) { @@ -96,7 +130,29 @@ fun ProvideCustomIntentInput( val returnedValue = simprintsCustomIntentFormPresenter.handleResult(result.resultCode, result.data) - if (result.resultCode != RESULT_OK || returnedValue == null) { + val possibleDuplicatesSearch = + if ( + result.resultCode == RESULT_OK && + returnedValue == null && + SimprintsIntentUtils.isRegisterCallout(fieldUiModel.customIntent) + ) { + simprintsResolvePossibleDuplicatesSearchUseCase( + fieldUid = fieldUiModel.uid, + resultCode = result.resultCode, + data = result.data, + ) + } else { + null + } + + if (possibleDuplicatesSearch != null && simprintsPossibleDuplicatesSearchHandler != null) { + customIntentState = CustomIntentState.LAUNCH + inputShellState = fieldUiModel.inputState() + if (supportingTextList.contains(errorGettingDataMessage)) { + supportingTextList.remove(errorGettingDataMessage) + } + simprintsPossibleDuplicatesSearchHandler(possibleDuplicatesSearch) + } else if (result.resultCode != RESULT_OK || returnedValue == null) { customIntentState = CustomIntentState.LAUNCH inputShellState = InputShellState.ERROR if (!supportingTextList.contains(errorGettingDataMessage)) { @@ -113,6 +169,9 @@ fun ProvideCustomIntentInput( fieldUiModel.valueType, ), ) + if (simprintsCustomIntentFormPresenter.hasPendingValue) { + simprintsSessionRepository.clear() + } } } val launcher = @@ -148,17 +207,15 @@ 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() + simprintsCustomIntentFormPresenter + .createLaunchIntent() ?.let(simprintsLauncher::launch) 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 = From b0221f0be302422af9b7e2a3ac2bded847b3038f Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Apr 2026 15:19:39 +0100 Subject: [PATCH 06/16] Simprints features in low memory conditions (with "Do not keep activities") --- .../simprints/SimprintsEnrollmentViewModel.kt | 65 ++++++--- ...eSingleBiometricSearchNavigationUseCase.kt | 2 + .../simprints/SimprintsSearchViewModel.kt | 12 +- .../enrollment/EnrollmentActivity.kt | 40 +++++- .../enrollment/EnrollmentPresenterImpl.kt | 2 + .../usescases/enrollment/FormInjector.kt | 33 +++-- .../searchTrackEntity/SearchTEActivity.kt | 4 +- .../searchTrackEntity/SearchTEIViewModel.kt | 10 +- .../SearchParametersScreen.kt | 123 +++++++++++++++++- .../provider/ParameterSelectorItemProvider.kt | 46 ++----- .../SimprintsEnrollmentViewModelTest.kt | 70 ++++++++-- ...gleBiometricSearchNavigationUseCaseTest.kt | 2 + .../simprints/SimprintsSearchViewModelTest.kt | 56 +++++++- .../enrollment/EnrollmentPresenterImplTest.kt | 33 +++-- .../SearchTEIViewModelTest.kt | 46 ++++--- .../main/java/org/dhis2/form/ui/FormView.kt | 119 +++++++++++------ .../dhis2/form/ui/FormViewFragmentFactory.kt | 3 + .../CustomIntentActivityResultContract.kt | 48 ++++++- .../inputfield/CustomIntentProvider.kt | 82 +++--------- .../CustomIntentActivityResultContractTest.kt | 81 ++++++++++++ 20 files changed, 639 insertions(+), 238 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index bdc2eee09fd..63f4057d3ef 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( @@ -55,35 +55,60 @@ class SimprintsEnrollmentViewModel( 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/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt b/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt index fd012a0fd62..841c29e797e 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt @@ -19,6 +19,7 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCase( val teiUid: String, val programUid: String?, val enrollmentUid: String?, + val isOnline: Boolean, ) suspend operator fun invoke( @@ -63,6 +64,7 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCase( 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 528b75d1d4a..6d2b669285d 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -32,6 +32,7 @@ class SimprintsSearchViewModel( val teiUid: String, val programUid: String?, val enrollmentUid: String?, + val isOnline: Boolean = false, ) sealed class DashboardAction { @@ -171,7 +172,15 @@ class SimprintsSearchViewModel( queryData: Map?>, ): PendingDashboardNavigation? { val searchFields = searchItems.toSearchFields() - if (!shouldAutoNavigateToSimprintsBiometricSearch(uid, value, searchFields)) { + val hasPendingMfidBiometricIdentification = + pendingSimprintsMfidBiometricIdentification?.let { + it.uid == uid && it.value == value + } == true + + if ( + !hasPendingMfidBiometricIdentification && + !shouldAutoNavigateToSimprintsBiometricSearch(uid, value, searchFields) + ) { return null } @@ -185,6 +194,7 @@ class SimprintsSearchViewModel( teiUid = navigationTarget.teiUid, programUid = navigationTarget.programUid, enrollmentUid = navigationTarget.enrollmentUid, + isOnline = navigationTarget.isOnline, ) }?.also { clearPendingSession() 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 e902549f196..e5d453e7d97 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt @@ -27,14 +27,16 @@ 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.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 @@ -59,6 +61,25 @@ class EnrollmentActivity : lateinit var binding: EnrollmentActivityBinding lateinit var mode: EnrollmentMode + private val simprintsCustomIntentActivityResultContract = CustomIntentActivityResultContract() + private val simprintsCustomIntentLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + try { + supportFragmentManager.executePendingTransactions() + formView.handleCustomIntentResult( + simprintsCustomIntentActivityResultContract.parseResult( + resultCode = result.resultCode, + intent = result.data, + ), + ) + } 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 { @@ -67,6 +88,7 @@ class EnrollmentActivity : presenter.onRegisterLastResult( resultCode = result.resultCode, data = result.data, + enrollmentUid = intent.getStringExtra(ENROLLMENT_UID_EXTRA), ) ) { RegisterLastResult.CONTINUE_FINISH -> { @@ -98,6 +120,7 @@ class EnrollmentActivity : presenter.onRegisterLastResult( resultCode = result.resultCode, data = result.data, + enrollmentUid = intent.getStringExtra(ENROLLMENT_UID_EXTRA), ) ) { RegisterLastResult.CONTINUE_FINISH -> { @@ -240,6 +263,21 @@ class EnrollmentActivity : ), locationProvider = locationProvider, dateEditionWarningHandler = dateEditionWarningHandler, + onLaunchSimprintsCustomIntent = { input -> + try { + simprintsCustomIntentLauncher.launch( + simprintsCustomIntentActivityResultContract.createIntent(this, input), + ) + } catch (e: Exception) { + 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()) { 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 da07e82515e..0aa05a6b0e4 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt @@ -173,11 +173,13 @@ class EnrollmentPresenterImpl( 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 ea290c20c25..818d05f4a9b 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/FormInjector.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/FormInjector.kt @@ -11,6 +11,7 @@ import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSe 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, @@ -27,6 +28,7 @@ fun AppCompatActivity.buildEnrollmentForm( config: EnrollmentFormBuilderConfig, locationProvider: LocationProvider, dateEditionWarningHandler: DateEditionWarningHandler, + onLaunchSimprintsCustomIntent: ((CustomIntentInput) -> Unit)? = null, onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)? = null, onFinish: () -> Unit, ): FormView = @@ -48,23 +50,20 @@ fun AppCompatActivity.buildEnrollmentForm( ) } }.onFinishDataEntry(onFinish) - .run { - if (onLaunchSimprintsPossibleDuplicatesSearch != null) { - onLaunchSimprintsPossibleDuplicatesSearch(onLaunchSimprintsPossibleDuplicatesSearch) - } else { - this - } - } - .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 44218425628..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 : @@ -322,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) } 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 1d758c2f787..cb099bf212a 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -1287,12 +1287,10 @@ class SearchTEIViewModel( onNavigationPageChanged(NavigationPage.LIST_VIEW) setListScreen() clearQueryData() - _simprintsNavigation.send( - SimprintsNavigationAction.OpenDashboard( - teiUid = navigation.teiUid, - programUid = navigation.programUid, - enrollmentUid = navigation.enrollmentUid, - ), + onTeiClick( + teiUid = navigation.teiUid, + enrollmentUid = navigation.enrollmentUid, + online = navigation.isOnline, ) } } 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 1f939c73677..ce001ed4df6 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,11 @@ package org.dhis2.usescases.searchTrackEntity.searchparameters +import android.app.Activity.RESULT_OK +import android.content.Intent import android.content.res.Configuration +import android.os.Bundle 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 @@ -29,6 +33,7 @@ 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 @@ -42,6 +47,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 @@ -49,11 +56,15 @@ 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.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 @@ -88,8 +99,56 @@ 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 remember { 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 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 + + if (uid != null && result.resultCode == RESULT_OK && returnedValue != null) { + showSimprintsBiometricNoMatchesMessage = false + onSimprintsBiometricIdentificationResult( + uid, + returnedValue, + result.data?.extras.hasSimprintsAutoOpenEligibleIdentification(), + ) + intentHandler( + FormIntent.OnSave( + uid = uid, + value = returnedValue, + valueType = valueType, + ), + ) + } else { + showSimprintsBiometricNoMatchesMessage = result.resultCode == RESULT_OK + } + } val scanContract = remember { ScanContract() } val qrScanLauncher = @@ -263,9 +322,19 @@ fun SearchParametersScreen( focusManager = focusManager, fieldUiModel = fieldUiModel, callback = callback, - onSimprintsBiometricIdentificationResult = onSimprintsBiometricIdentificationResult, - onSimprintsBiometricSearchNoMatchesChanged = { - showSimprintsBiometricNoMatchesMessage = it + 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 @@ -378,6 +447,54 @@ 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 { + Gson().fromJson>( + it, + object : TypeToken>() {}.type, + ) + } ?: 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 Bundle?.hasSimprintsAutoOpenEligibleIdentification(): Boolean = + this?.keySet()?.any { extraName -> + getString(extraName)?.hasSimprintsAutoOpenEligibleIdentification() == true + } == true + +internal fun String.hasSimprintsAutoOpenEligibleIdentification(): Boolean = + SIMPRINTS_IDENTIFICATION_JSON_OBJECT.findAll(this).any { identification -> + SIMPRINTS_MFID_CREDENTIAL_LINKED_KEY.containsMatchIn(identification.value) && + !SIMPRINTS_MFID_CREDENTIAL_VERIFIED_FALSE_KEY.containsMatchIn(identification.value) + } + +private val SIMPRINTS_IDENTIFICATION_JSON_OBJECT = Regex("\\{[^{}]*\\}") +private val SIMPRINTS_MFID_CREDENTIAL_LINKED_KEY = Regex("\"isLinkedToCredential\"\\s*:\\s*true") +private val SIMPRINTS_MFID_CREDENTIAL_VERIFIED_FALSE_KEY = Regex("\"isVerified\"\\s*:\\s*false") + @Preview(showBackground = true) @Composable fun SearchFormPreviewWithClear() { 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 d3aeb4b130d..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,8 +1,6 @@ package org.dhis2.usescases.searchTrackEntity.searchparameters.provider -import android.app.Activity.RESULT_OK -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts +import android.content.Intent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material.icons.outlined.QrCode2 @@ -15,16 +13,15 @@ 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.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase 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.intent.FormIntent import org.dhis2.form.ui.provider.inputfield.FieldProvider import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle @@ -39,8 +36,7 @@ fun provideParameterSelectorItem( fieldUiModel: FieldUiModel, callback: FieldUiModel.Callback, onNextClicked: () -> Unit, - onSimprintsBiometricIdentificationResult: (String, String?, Boolean) -> Unit, - onSimprintsBiometricSearchNoMatchesChanged: (Boolean) -> Unit = {}, + onSimprintsBiometricIdentificationLaunch: (String, ValueType?, String?, Boolean, Intent) -> Unit, ): ParameterSelectorItemModel { val focusRequester = remember { FocusRequester() } val context = LocalContext.current.applicationContext @@ -54,31 +50,6 @@ fun provideParameterSelectorItem( resources = resources, sessionRepository = simprintsSessionRepository, ) - val hasAutoOpenEligibleSimprintsIdentification = - remember { SimprintsHasAutoOpenEligibleIdentificationUseCase() } - val simprintsIdentifyLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val returnedValue = - simprintsCustomIntentFormPresenter.handleResult(result.resultCode, result.data) - - if (result.resultCode == RESULT_OK && returnedValue != null) { - onSimprintsBiometricSearchNoMatchesChanged(false) - onSimprintsBiometricIdentificationResult( - fieldUiModel.uid, - returnedValue, - hasAutoOpenEligibleSimprintsIdentification(result.data?.extras), - ) - callback.intent( - FormIntent.OnSave( - uid = fieldUiModel.uid, - value = returnedValue, - valueType = fieldUiModel.valueType, - ), - ) - } else { - onSimprintsBiometricSearchNoMatchesChanged(result.resultCode == RESULT_OK) - } - } val status = if (fieldUiModel.focused) { @@ -121,11 +92,18 @@ fun provideParameterSelectorItem( onExpand = { if (SimprintsIntentUtils.isIdentifyCallout(fieldUiModel.customIntent)) { if (!simprintsCustomIntentFormPresenter.hasPendingValue) { - onSimprintsBiometricSearchNoMatchesChanged(false) simprintsCustomIntentFormPresenter.prepareLaunch() simprintsCustomIntentFormPresenter .createLaunchIntent() - ?.let(simprintsIdentifyLauncher::launch) + ?.let { launchIntent -> + onSimprintsBiometricIdentificationLaunch( + fieldUiModel.uid, + fieldUiModel.valueType, + fieldUiModel.customIntent?.customIntentResponse?.let(Gson()::toJson), + SimprintsIntentUtils.isIdentifyCallout(fieldUiModel.customIntent), + launchIntent, + ) + } } return@ParameterSelectorItemModel } diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt index 32fa230dfdf..9db0562e3d9 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt @@ -116,6 +116,7 @@ class SimprintsEnrollmentViewModelTest { resultCode = RESULT_OK, data = resultIntent, teiUid = "tei-uid", + enrollmentUid = "enrollment-uid", ) assertSame(launchIntent, preparedIntent) @@ -175,6 +176,7 @@ class SimprintsEnrollmentViewModelTest { resultCode = RESULT_OK, data = resultIntent, teiUid = "tei-uid", + enrollmentUid = "enrollment-uid", ) assertSame(launchIntent, preparedIntent) @@ -232,6 +234,7 @@ class SimprintsEnrollmentViewModelTest { resultCode = RESULT_OK, data = resultIntent, teiUid = "tei-uid", + enrollmentUid = "enrollment-uid", ) assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.ERROR, result) @@ -240,6 +243,7 @@ class SimprintsEnrollmentViewModelTest { any(), any(), ) + verify(sessionRepository).clearPendingEnrollment() verify(sessionRepository, never()).clear() } @@ -278,10 +282,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 @@ -300,21 +307,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" @@ -332,17 +348,55 @@ class SimprintsEnrollmentViewModelTest { resultMapper = resultMapper, ) - viewModel.onFinishRequested( - 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/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt index ab2c939b4bd..eddc1dc31bb 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt @@ -70,6 +70,7 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCaseTest { teiUid = "teiUid", programUid = "matchedProgramUid", enrollmentUid = "enrollmentUid", + isOnline = true, ), result, ) @@ -107,6 +108,7 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCaseTest { teiUid = "teiUid", programUid = "initialProgramUid", enrollmentUid = null, + isOnline = true, ), result, ) diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt index ce8724e1b54..3efb498a79e 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt @@ -380,6 +380,7 @@ class SimprintsSearchViewModelTest { teiUid = "teiUid", programUid = "programUid", enrollmentUid = "enrollmentUid", + isOnline = false, ) val viewModel = SimprintsSearchViewModel( @@ -418,6 +419,7 @@ class SimprintsSearchViewModelTest { teiUid = "teiUid", programUid = "programUid", enrollmentUid = "enrollmentUid", + isOnline = false, ) val viewModel = SimprintsSearchViewModel( @@ -467,6 +469,7 @@ class SimprintsSearchViewModelTest { teiUid = "teiUid", programUid = "programUid", enrollmentUid = "enrollmentUid", + isOnline = false, ) val viewModel = SimprintsSearchViewModel( @@ -495,6 +498,50 @@ class SimprintsSearchViewModelTest { 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 clear only biometric search items`() { val viewModel = @@ -503,10 +550,11 @@ class SimprintsSearchViewModelTest { sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, ) - val queryData = mutableMapOf?>( - "biometric" to listOf("guid-1"), - "name" to listOf("Name"), - ) + val queryData = + mutableMapOf?>( + "biometric" to listOf("guid-1"), + "name" to listOf("Name"), + ) val updatedItems = viewModel.clearSimprintsBiometricQueryData( 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 158ec3f9547..4ce9b67fd85 100644 --- a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt @@ -34,11 +34,11 @@ 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 import org.mockito.kotlin.verify -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.whenever class EnrollmentPresenterImplTest { @@ -267,18 +267,19 @@ class EnrollmentPresenterImplTest { } @Test - fun `Should delegate finish request to Simprints enrollment view model`() = runTest { - val intent: Intent = mock() - whenever( - simprintsEnrollmentViewModel.onFinishRequested( - 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("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`() = @@ -286,6 +287,7 @@ class EnrollmentPresenterImplTest { val enrollment: Enrollment = mock { on { trackedEntityInstance() } doReturn "teiUid" + on { uid() } doReturn "enrollmentUid" } whenever(enrollmentRepository.blockingGet()) doReturn enrollment whenever( @@ -293,15 +295,22 @@ class EnrollmentPresenterImplTest { 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 85454690999..ccc024bb020 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -872,6 +872,7 @@ class SearchTEIViewModelTest { teiUid = "teiUid", programUid = "matchedProgramUid", enrollmentUid = "enrollmentUid", + isOnline = true, ) viewModel.onNavigationPageChanged(NavigationPage.MAP_VIEW) viewModel.setMapScreen() @@ -888,31 +889,28 @@ class SearchTEIViewModelTest { ), ) - viewModel.simprintsNavigation.test { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "biometric", - value = "guid-1", - valueType = ValueType.TEXT, - ), - ) - testingDispatcher.scheduler.advanceUntilIdle() + viewModel.onParameterIntent( + FormIntent.OnSave( + uid = "biometric", + value = "guid-1", + valueType = ValueType.TEXT, + ), + ) + testingDispatcher.scheduler.advanceUntilIdle() - val action = awaitItem() - assertTrue(action is SimprintsNavigationAction.OpenDashboard) - action as SimprintsNavigationAction.OpenDashboard - assertEquals("teiUid", action.teiUid) - assertEquals("matchedProgramUid", action.programUid) - assertEquals("enrollmentUid", action.enrollmentUid) - 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()) - assertEquals(null, viewModel.searchParametersUiState.items.single().value) - assertEquals(null, viewModel.searchParametersUiState.items.single().displayName) - cancelAndIgnoreRemainingEvents() - } + 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()) + assertEquals(null, viewModel.searchParametersUiState.items.single().value) + assertEquals(null, viewModel.searchParametersUiState.items.single().displayName) } @Test 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 946724cfd08..fa5c4a4e82d 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormView.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormView.kt @@ -12,7 +12,6 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -43,6 +42,7 @@ 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 @@ -54,7 +54,6 @@ import org.dhis2.form.model.InfoUiModel import org.dhis2.form.model.RowAction import org.dhis2.form.model.UiRenderType import org.dhis2.form.model.exception.RepositoryRecordsException -import org.dhis2.form.simprints.LocalSimprintsPossibleDuplicatesSearchHandler import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract import org.dhis2.form.ui.customintent.CustomIntentInput import org.dhis2.form.ui.customintent.CustomIntentResult @@ -80,6 +79,7 @@ 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 @@ -136,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 { @@ -176,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 @@ -219,14 +200,12 @@ class FormView : Fragment() { } } - CompositionLocalProvider(LocalSimprintsPossibleDuplicatesSearchHandler provides onLaunchSimprintsPossibleDuplicatesSearch) { - Form( - sections = sections, - intentHandler = ::intentHandler, - uiEventHandler = ::uiEventHandler, - resources = Injector.provideResourcesManager(context), - ) - } + Form( + sections = sections, + intentHandler = ::intentHandler, + uiEventHandler = ::uiEventHandler, + resources = Injector.provideResourcesManager(context), + ) resultDialogData?.let { DataEntryBottomSheet( @@ -362,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) + } } } @@ -626,6 +613,7 @@ class FormView : Fragment() { onFocused: (() -> Unit)?, onFinishDataEntry: (() -> Unit)?, onActivityForResult: (() -> Unit)?, + onLaunchSimprintsCustomIntent: ((CustomIntentInput) -> Unit)?, onLaunchSimprintsPossibleDuplicatesSearch: ((SimprintsPossibleDuplicatesSearch) -> Unit)?, onFieldItemsRendered: ((fieldsEmpty: Boolean) -> Unit)?, ) { @@ -634,10 +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 @@ -647,6 +686,7 @@ 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 @@ -682,6 +722,8 @@ 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 } @@ -708,6 +750,7 @@ class FormView : Fragment() { onFocused, onFinishDataEntry, onActivityForResult, + onLaunchSimprintsCustomIntent, onLaunchSimprintsPossibleDuplicatesSearch, onPercentageUpdate, onFieldItemsRendered, 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 08fe8b98dd9..4c1e6531f84 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewFragmentFactory.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewFragmentFactory.kt @@ -5,6 +5,7 @@ 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?, @@ -13,6 +14,7 @@ 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)?, @@ -33,6 +35,7 @@ class FormViewFragmentFactory( onFocused = onFocused, onFinishDataEntry = onFinishDataEntry, onActivityForResult = onActivityForResult, + onLaunchSimprintsCustomIntent = onLaunchSimprintsCustomIntent, onLaunchSimprintsPossibleDuplicatesSearch = onLaunchSimprintsPossibleDuplicatesSearch, onFieldItemsRendered = onFieldItemsRendered, ) 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..b11fda219b0 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 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 @@ -18,6 +20,8 @@ class CustomIntentActivityResultContract : ActivityResultContract + mapIntentResponseData( + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = extraName, + extraType = CustomIntentResponseExtraType.LIST_OF_OBJECTS, + key = SIMPRINTS_GUID_KEY, + ), + ), + intent = intent, + ) + } ?: return null + + return CustomIntentResult.PossibleDuplicates( + fieldUid = fieldUid, + guidValues = guidValues, + extras = intent?.extras, + ) + } + fun mapIntentData( packageName: String, requestParameters: List, @@ -193,9 +231,17 @@ 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 f669a943048..d6e8a1b4fda 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,8 +1,6 @@ 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 @@ -19,15 +17,11 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.simprints.usecases.SimprintsExtractIdentificationMatchesUseCase -import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSearchUseCase -import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.form.R import org.dhis2.form.di.Injector import org.dhis2.form.extensions.inputState import org.dhis2.form.extensions.supportingText import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.simprints.LocalSimprintsPossibleDuplicatesSearchHandler import org.dhis2.form.simprints.rememberSimprintsCustomIntentFormPresenter import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract import org.dhis2.form.ui.customintent.CustomIntentInput @@ -52,7 +46,6 @@ fun ProvideCustomIntentInput( modifier: Modifier, ) { val context = LocalContext.current.applicationContext - val simprintsPossibleDuplicatesSearchHandler = LocalSimprintsPossibleDuplicatesSearchHandler.current val values = remember(fieldUiModel) { fieldUiModel.value?.takeIf { it.isNotEmpty() }?.let { value -> @@ -104,14 +97,6 @@ fun ProvideCustomIntentInput( lifecycleOwner.lifecycle.removeObserver(observer) } } - - val simprintsResolvePossibleDuplicatesSearchUseCase = - remember(simprintsSessionRepository) { - SimprintsResolvePossibleDuplicatesSearchUseCase( - extractIdentificationMatches = SimprintsExtractIdentificationMatchesUseCase(), - sessionRepository = simprintsSessionRepository, - ) - } LaunchedEffect( fieldUiModel.value, fieldUiModel.isLoadingData, @@ -125,55 +110,6 @@ fun ProvideCustomIntentInput( } customIntentState = getCustomIntentState(values, fieldUiModel.isLoadingData) } - val simprintsLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - val returnedValue = - simprintsCustomIntentFormPresenter.handleResult(result.resultCode, result.data) - - val possibleDuplicatesSearch = - if ( - result.resultCode == RESULT_OK && - returnedValue == null && - SimprintsIntentUtils.isRegisterCallout(fieldUiModel.customIntent) - ) { - simprintsResolvePossibleDuplicatesSearchUseCase( - fieldUid = fieldUiModel.uid, - resultCode = result.resultCode, - data = result.data, - ) - } else { - null - } - - if (possibleDuplicatesSearch != null && simprintsPossibleDuplicatesSearchHandler != null) { - customIntentState = CustomIntentState.LAUNCH - inputShellState = fieldUiModel.inputState() - if (supportingTextList.contains(errorGettingDataMessage)) { - supportingTextList.remove(errorGettingDataMessage) - } - simprintsPossibleDuplicatesSearchHandler(possibleDuplicatesSearch) - } else 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, - ), - ) - if (simprintsCustomIntentFormPresenter.hasPendingValue) { - simprintsSessionRepository.clear() - } - } - } val launcher = rememberLauncherForActivityResult(contract = CustomIntentActivityResultContract()) { when (it) { @@ -196,6 +132,15 @@ fun ProvideCustomIntentInput( ), ) } + is CustomIntentResult.PossibleDuplicates -> { + customIntentState = CustomIntentState.LAUNCH + inputShellState = InputShellState.ERROR + if (!supportingTextList.contains(errorGettingDataMessage)) { + supportingTextList.add( + errorGettingDataMessage, + ) + } + } } } InputCustomIntent( @@ -214,9 +159,12 @@ fun ProvideCustomIntentInput( 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/test/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContractTest.kt b/form/src/test/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContractTest.kt index ec6153e26b5..ed9937588e6 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,82 @@ class CustomIntentActivityResultContractTest { assertEquals(3, result?.size) assertEquals(listOf("test", "42", "123"), result) } + + @Test + fun `parseResult should return possible duplicates for Simprints identifications`() { + prepareParseResult( + 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(android.app.Activity.RESULT_OK, intent) + + 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`() { + prepareParseResult( + fieldUid = "field-uid", + customIntent = simprintsIdentifyIntent(), + ) + val intent = + Intent().apply { + putExtra("identifications", """[{"guid":"g1"}]""") + } + + val result = contract.parseResult(android.app.Activity.RESULT_OK, intent) + + assertTrue(result is CustomIntentResult.Error) + } + + 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 prepareParseResult( + fieldUid: String, + customIntent: CustomIntentModel, + ) { + CustomIntentActivityResultContract::class.java + .getDeclaredField("fieldUid") + .apply { isAccessible = true } + .set(null, fieldUid) + CustomIntentActivityResultContract::class.java + .getDeclaredField("customIntent") + .apply { isAccessible = true } + .set(null, customIntent) + } } From 5b978928a87c0f5d892f0cfea95f54d112715c92 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Apr 2026 15:41:45 +0100 Subject: [PATCH 07/16] Simprints features in low memory conditions (with "No background processes") --- .../simprints/SimprintsEnrollmentViewModel.kt | 2 +- .../SimprintsEnrollmentViewModelTest.kt | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index 63f4057d3ef..378b9204dce 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -39,7 +39,6 @@ class SimprintsEnrollmentViewModel( } suspend fun onAutoEnrollLastRequested(enrollmentUid: String): Intent? { - sessionRepository.clearPendingEnrollment() val sessionId = sessionRepository.get()?.takeIf(String::isNotBlank) ?: return null val resolvedAction = resolvePendingEnrollmentAction( @@ -48,6 +47,7 @@ class SimprintsEnrollmentViewModel( ) ?: return null pendingAction.store(resolvedAction) + sessionRepository.markPendingEnrollmentFromPossibleDuplicates() return resolvedAction.callout.launchIntent } diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt index 9db0562e3d9..7167c919bf3 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt @@ -180,6 +180,61 @@ class SimprintsEnrollmentViewModelTest { ) 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", From 6c3a5a1bbc08ca2c43409148bee61aec2b445d09 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Apr 2026 16:21:38 +0100 Subject: [PATCH 08/16] Simprints empty Identification handling fix in low memory conditions (with "Do not keep activities") "No biometric search matches" label shows instead incorrectly shown unfiltered record list --- .../searchTrackEntity/SearchTEIViewModel.kt | 31 +++++++++++--- .../SearchParametersScreen.kt | 26 ++++++++++-- .../SearchTEIViewModelTest.kt | 42 +++++++++++++++---- 3 files changed, 83 insertions(+), 16 deletions(-) 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 cb099bf212a..7aefc1d7cc0 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -56,10 +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 @@ -182,6 +182,7 @@ class SearchTEIViewModel( private val onNewSearch = MutableSharedFlow(extraBufferCapacity = 1) private var simprintsPossibleDuplicatesAutoNoneOfAboveTriggered: Boolean = false + private var keepSearchScreenOpenForSimprintsBiometricNoMatches: Boolean = false private val loadSimprintsPossibleDuplicatesSearchResultsUseCase = SimprintsLoadPossibleDuplicatesSearchResultsUseCase(searchRepository, searchRepositoryKt) @@ -289,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) { @@ -320,7 +323,7 @@ class SearchTEIViewModel( .getProgram(initialProgramUid) ?.minAttributesRequiredToSearch() ?: 1, - isForced = shouldOpenSearch, + isForced = shouldForceSearch, isOpened = shouldOpenSearch, ), searchFilters = @@ -469,6 +472,7 @@ class SearchTEIViewModel( } fun clearQueryData() { + clearSimprintsBiometricNoMatchesState() queryData.clear() clearSearchParameters() simprintsSearchViewModel.clearPendingSession() @@ -503,6 +507,7 @@ class SearchTEIViewModel( } fun onSimprintsBiometricSearchNavigation() { + clearSimprintsBiometricNoMatchesState() onNavigationPageChanged(NavigationPage.LIST_VIEW) setListScreen() searchRepository.clearFetchedList() @@ -719,6 +724,7 @@ class SearchTEIViewModel( } fun onSearch() { + clearSimprintsBiometricNoMatchesState() searchRepository.clearFetchedList() performSearch() } @@ -737,7 +743,8 @@ class SearchTEIViewModel( when (_screenState.value?.screenState) { SearchScreenState.LIST, SearchScreenState.NONE, - null -> { + null, + -> { setListScreen() onNewSearch.emit(Unit) } @@ -883,6 +890,7 @@ class SearchTEIViewModel( value: String?, hasAutoOpenEligibleSimprintsIdentification: Boolean, ) { + clearSimprintsBiometricNoMatchesState() simprintsSearchViewModel.onSimprintsBiometricIdentificationResult( uid = uid, value = value, @@ -890,6 +898,15 @@ class SearchTEIViewModel( ) } + fun onSimprintsBiometricNoMatches() { + keepSearchScreenOpenForSimprintsBiometricNoMatches = true + setSearchScreen() + } + + fun clearSimprintsBiometricNoMatchesState() { + keepSearchScreenOpenForSimprintsBiometricNoMatches = false + } + fun refreshSimprintsUiState() { simprintsSearchViewModel.refreshSimprintsUiState(searchParametersUiState.items) } @@ -1425,7 +1442,11 @@ class SearchTEIViewModel( val isSimprintsPossibleDuplicatesSearch = _isSimprintsPossibleDuplicatesSearch.value == true map[item.uid] = resourceManager.getString( - if (isSimprintsPossibleDuplicatesSearch) R.string.simprints_possible_duplicates else R.string.simprints_biometric_search, + if (isSimprintsPossibleDuplicatesSearch) { + R.string.simprints_possible_duplicates + } else { + R.string.simprints_biometric_search + }, ) return@forEach } 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 ce001ed4df6..fec9e8aff93 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 @@ -86,6 +86,7 @@ fun SearchParametersScreen( uiState: SearchParametersUiState, intentHandler: (FormIntent) -> Unit, onSimprintsBiometricIdentificationResult: (String, String?, Boolean) -> Unit, + onSimprintsBiometricNoMatches: () -> Unit, onShowOrgUnit: ( uid: String, preselectedOrgUnits: List, @@ -101,7 +102,7 @@ fun SearchParametersScreen( val focusManager = LocalFocusManager.current val context = LocalContext.current.applicationContext val configuration = LocalConfiguration.current - var showSimprintsBiometricNoMatchesMessage by remember { mutableStateOf(false) } + var showSimprintsBiometricNoMatchesMessage by rememberSaveable { mutableStateOf(false) } var pendingSimprintsFieldUid by rememberSaveable { mutableStateOf(null) } var pendingSimprintsValueTypeName by rememberSaveable { mutableStateOf(null) } var pendingSimprintsResponseDataJson by rememberSaveable { mutableStateOf(null) } @@ -131,6 +132,11 @@ fun SearchParametersScreen( pendingSimprintsResponseDataJson = null pendingSimprintsCapturesSessionId = false + val shouldShowNoMatchesMessage = + shouldShowSimprintsBiometricNoMatchesMessage( + resultCode = result.resultCode, + returnedValue = returnedValue, + ) if (uid != null && result.resultCode == RESULT_OK && returnedValue != null) { showSimprintsBiometricNoMatchesMessage = false onSimprintsBiometricIdentificationResult( @@ -146,7 +152,10 @@ fun SearchParametersScreen( ), ) } else { - showSimprintsBiometricNoMatchesMessage = result.resultCode == RESULT_OK + if (shouldShowNoMatchesMessage) { + onSimprintsBiometricNoMatches() + } + showSimprintsBiometricNoMatchesMessage = shouldShowNoMatchesMessage } } @@ -440,6 +449,7 @@ fun SearchFormPreview() { ), intentHandler = {}, onSimprintsBiometricIdentificationResult = { _, _, _ -> }, + onSimprintsBiometricNoMatches = {}, onShowOrgUnit = { _, _, _, _ -> }, onSearch = {}, onClear = {}, @@ -480,6 +490,11 @@ private fun mapPendingSimprintsSearchResult( return returnedValue } +internal fun shouldShowSimprintsBiometricNoMatchesMessage( + resultCode: Int, + returnedValue: String?, +): Boolean = resultCode == RESULT_OK && returnedValue == null + internal fun Bundle?.hasSimprintsAutoOpenEligibleIdentification(): Boolean = this?.keySet()?.any { extraName -> getString(extraName)?.hasSimprintsAutoOpenEligibleIdentification() == true @@ -520,6 +535,7 @@ fun SearchFormPreviewWithClear() { ), intentHandler = {}, onSimprintsBiometricIdentificationResult = { _, _, _ -> }, + onSimprintsBiometricNoMatches = {}, onShowOrgUnit = { _, _, _, _ -> }, onSearch = {}, onClear = {}, @@ -552,13 +568,17 @@ fun initSearchScreen( 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/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt index ccc024bb020..bcc7ee39226 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -49,6 +49,8 @@ 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 @@ -204,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() @@ -909,8 +933,9 @@ class SearchTEIViewModelTest { assertTrue(viewModel.queryData.isEmpty()) assertTrue(!viewModel.searchParametersUiState.clearSearchEnabled) assertTrue(viewModel.searchParametersUiState.searchedItems.isEmpty()) - assertEquals(null, viewModel.searchParametersUiState.items.single().value) - assertEquals(null, viewModel.searchParametersUiState.items.single().displayName) + val searchItem = viewModel.searchParametersUiState.items.single() + assertNull(searchItem.value) + assertNull(searchItem.displayName) } @Test @@ -1616,12 +1641,13 @@ class SearchTEIViewModelTest { teiUid: String = header, ) = SearchTeiModel().apply { setHeader(header) - tei = TrackedEntityInstance - .builder() - .uid(teiUid) - .trackedEntityType("teiType") - .organisationUnit("orgUnit") - .build() + tei = + TrackedEntityInstance + .builder() + .uid(teiUid) + .trackedEntityType("teiType") + .organisationUnit("orgUnit") + .build() } private fun testingProgram( From e15426dee0068e43253d9a6ce5a205500019dd27 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Apr 2026 16:44:21 +0100 Subject: [PATCH 09/16] Simprints Enrolment+ & search optimizations README updates --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 7b6896c4bfb..4776f6533d5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,18 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | 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 | +| 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#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 | | 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 | @@ -48,6 +60,14 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | 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 From ddf7b136d302747a2a4b3678c79c792d49b2ecb8 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Apr 2026 17:22:09 +0100 Subject: [PATCH 10/16] Simprints Enrolment+ & search optimizations README older reference updates --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4776f6533d5..6ef96328ed8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 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 | +| 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 | @@ -27,18 +27,18 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | 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#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 | +| 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 | @@ -49,14 +49,14 @@ 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 | From 50a11fbc981c03d889a6f1fe5f36aac995f60053 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 22 Apr 2026 11:02:06 +0100 Subject: [PATCH 11/16] Simprints MFID Auto-Open Record eligibility logic unified --- .../SearchParametersScreen.kt | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) 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 fec9e8aff93..d076126d6eb 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 @@ -3,7 +3,6 @@ package org.dhis2.usescases.searchTrackEntity.searchparameters import android.app.Activity.RESULT_OK import android.content.Intent import android.content.res.Configuration -import android.os.Bundle import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -56,6 +55,7 @@ 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 @@ -112,6 +112,10 @@ fun SearchParametersScreen( remember(context) { Injector.provideSimprintsSessionRepository(context) } + val simprintsHasAutoOpenEligibleIdentificationUseCase = + remember { + SimprintsHasAutoOpenEligibleIdentificationUseCase() + } val simprintsIdentifyLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val uid = pendingSimprintsFieldUid @@ -142,7 +146,7 @@ fun SearchParametersScreen( onSimprintsBiometricIdentificationResult( uid, returnedValue, - result.data?.extras.hasSimprintsAutoOpenEligibleIdentification(), + simprintsHasAutoOpenEligibleIdentificationUseCase(result.data?.extras), ) intentHandler( FormIntent.OnSave( @@ -495,21 +499,6 @@ internal fun shouldShowSimprintsBiometricNoMatchesMessage( returnedValue: String?, ): Boolean = resultCode == RESULT_OK && returnedValue == null -internal fun Bundle?.hasSimprintsAutoOpenEligibleIdentification(): Boolean = - this?.keySet()?.any { extraName -> - getString(extraName)?.hasSimprintsAutoOpenEligibleIdentification() == true - } == true - -internal fun String.hasSimprintsAutoOpenEligibleIdentification(): Boolean = - SIMPRINTS_IDENTIFICATION_JSON_OBJECT.findAll(this).any { identification -> - SIMPRINTS_MFID_CREDENTIAL_LINKED_KEY.containsMatchIn(identification.value) && - !SIMPRINTS_MFID_CREDENTIAL_VERIFIED_FALSE_KEY.containsMatchIn(identification.value) - } - -private val SIMPRINTS_IDENTIFICATION_JSON_OBJECT = Regex("\\{[^{}]*\\}") -private val SIMPRINTS_MFID_CREDENTIAL_LINKED_KEY = Regex("\"isLinkedToCredential\"\\s*:\\s*true") -private val SIMPRINTS_MFID_CREDENTIAL_VERIFIED_FALSE_KEY = Regex("\"isVerified\"\\s*:\\s*false") - @Preview(showBackground = true) @Composable fun SearchFormPreviewWithClear() { From efd3f3f98d1233c6733e0a2f281646077bf15c48 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 22 Apr 2026 11:06:27 +0100 Subject: [PATCH 12/16] Simprints intent response JSON parsing exception handling fix for Timber API usage & PII exposure prevention --- .../searchparameters/SearchParametersScreen.kt | 14 ++++++++++---- .../repository/SimprintsSessionRepository.kt | 8 +++++++- ...SimprintsExtractIdentificationMatchesUseCase.kt | 9 +++++++-- ...intsHasAutoOpenEligibleIdentificationUseCase.kt | 2 +- 4 files changed, 25 insertions(+), 8 deletions(-) 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 d076126d6eb..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 @@ -79,6 +79,7 @@ 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( @@ -475,10 +476,15 @@ private fun mapPendingSimprintsSearchResult( val responseData = responseDataJson ?.let { - Gson().fromJson>( - it, - object : TypeToken>() {}.type, - ) + try { + Gson().fromJson>( + it, + object : TypeToken>() {}.type, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse CustomIntentResponseDataModel") + null + } } ?: return null val returnedValue = 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 8028fee6122..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,6 +1,7 @@ package org.dhis2.commons.simprints.repository import org.dhis2.commons.prefs.PreferenceProvider +import timber.log.Timber class SimprintsSessionRepository( private val preferenceProvider: PreferenceProvider, @@ -44,7 +45,12 @@ class SimprintsSessionRepository( hasPendingEnrollment() && preferenceProvider.getString(PENDING_ENROLL_LAST_SOURCE) ?.let { - runCatching { PendingEnrollmentSource.valueOf(it) }.getOrNull() + try { + PendingEnrollmentSource.valueOf(it) + } catch (e: Exception) { + Timber.e(e, "Failed to parse Simprints pending enrollment source") + null + } } == PendingEnrollmentSource.POSSIBLE_DUPLICATES fun clearPendingEnrollment() { 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 index ff655bf48ed..2a8d0a54906 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsExtractIdentificationMatchesUseCase.kt @@ -26,7 +26,7 @@ class SimprintsExtractIdentificationMatchesUseCase { try { JsonParser.parseString(jsonString) } catch (e: Exception) { - Timber.e(e, "Failed to parse JSON element in Simprints identification response: $jsonString") + Timber.e(e, "Failed to parse JSON element in Simprints identification response") null } @@ -57,7 +57,12 @@ class SimprintsExtractIdentificationMatchesUseCase { get(SIMPRINTS_CONFIDENCE_KEY) ?.takeIf { !it.isJsonNull } ?.let { value -> - runCatching { value.asFloat }.getOrNull() + try { + value.asFloat + } catch (e: Exception) { + Timber.e(e, "Failed to parse confidence value in Simprints identification response") + null + } } return SimprintsIdentificationMatch( 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 index d23460c571c..389f605c8df 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsHasAutoOpenEligibleIdentificationUseCase.kt @@ -28,7 +28,7 @@ class SimprintsHasAutoOpenEligibleIdentificationUseCase { try { JsonParser.parseString(jsonString) } catch (e: JsonSyntaxException) { - Timber.e("Failed to parse JSON element in Simprints identification response: $jsonString", e) + Timber.e(e, "Failed to parse JSON element in Simprints identification response") null } From 381979c7aaa1d0ab50edab88e9ad63769c2d618d Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 4 May 2026 10:49:59 +0100 Subject: [PATCH 13/16] Simprints Sequential search: Identification+ features added to the non-biometric followup step --- .../simprints/SimprintsSearchViewModel.kt | 38 +++- .../simprints/SimprintsSearchViewModelTest.kt | 203 +++++++++++++++++- ...ntsResolveConfirmIdentityCalloutUseCase.kt | 3 +- ...esolveConfirmIdentityCalloutUseCaseTest.kt | 44 ++++ 4 files changed, 272 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index 6d2b669285d..267efa8c1f8 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -48,6 +48,7 @@ class SimprintsSearchViewModel( private val pendingDashboardNavigation = AtomicReference(null) private var pendingSimprintsMfidBiometricIdentification: PendingSimprintsMfidBiometricIdentification? = null + private var useLastBiometricsForSequentialSearch = false private val _simprintsBiometricSearchNavigation = Channel(Channel.BUFFERED) val simprintsBiometricSearchNavigation: Flow = _simprintsBiometricSearchNavigation.receiveAsFlow() @@ -65,14 +66,18 @@ class SimprintsSearchViewModel( ): 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 } @@ -88,6 +93,8 @@ class SimprintsSearchViewModel( pendingDashboardNavigation.store(navigation) if (!keepSession) { + pendingSimprintsMfidBiometricIdentification = null + useLastBiometricsForSequentialSearch = false sessionRepository.clear() } return DashboardAction.LaunchConfirmIdentity(confirmIdentityIntent) @@ -100,7 +107,10 @@ class SimprintsSearchViewModel( val searchFields = searchItems.toSearchFields() val searchState = SimprintsSearchUtils.searchState(searchFields) - if (searchState.hasBiometricIdentificationQuery && sessionRepository.hasPendingSession()) { + if ( + sessionRepository.hasPendingSession() && + (searchState.hasBiometricIdentificationQuery || shouldUseSequentialSearchLastBiometrics(searchState)) + ) { sessionRepository.markPendingEnrollment() } @@ -126,14 +136,20 @@ class SimprintsSearchViewModel( fun clearPendingSession() { pendingDashboardNavigation.store(null) pendingSimprintsMfidBiometricIdentification = 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 = null + useLastBiometricsForSequentialSearch = false sessionRepository.clear() } } @@ -147,7 +163,7 @@ class SimprintsSearchViewModel( SimprintsSearchUtils.shouldUseLastBiometricsLabel( searchState = searchState, hasPendingSession = sessionRepository.hasPendingSession(), - ), + ) || shouldUseSequentialSearchLastBiometrics(searchState), ) } @@ -156,6 +172,7 @@ class SimprintsSearchViewModel( value: String?, hasAutoOpenEligibleSimprintsIdentification: Boolean, ) { + useLastBiometricsForSequentialSearch = false pendingSimprintsMfidBiometricIdentification = if (!value.isNullOrBlank() && hasAutoOpenEligibleSimprintsIdentification) { PendingSimprintsMfidBiometricIdentification(uid = uid, value = value) @@ -221,7 +238,7 @@ class SimprintsSearchViewModel( } queryData.keys.removeAll(simprintsBiometricFieldUids) - clearPendingSession() + useLastBiometricsForSequentialSearch = sessionRepository.hasPendingSession() return searchItems.map { field -> if (field.uid in simprintsBiometricFieldUids) { @@ -229,7 +246,7 @@ class SimprintsSearchViewModel( } else { field } - } + }.also(::refreshSimprintsUiState) } fun shouldUseLastBiometricsLabel(searchItems: List): Boolean { @@ -237,9 +254,16 @@ class SimprintsSearchViewModel( 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?, diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt index 3efb498a79e..d0f06674cec 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt @@ -22,6 +22,7 @@ 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 @@ -42,7 +43,7 @@ class SimprintsSearchViewModelTest { 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(), @@ -81,7 +82,7 @@ class SimprintsSearchViewModelTest { 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(), @@ -110,7 +111,7 @@ class SimprintsSearchViewModelTest { 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(), @@ -146,7 +147,7 @@ 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, @@ -166,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 @@ -214,6 +265,71 @@ class SimprintsSearchViewModelTest { 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( + searchItems = searchItems, + queryData = queryData, + ) + + assertEquals(hashMapOf>(), filteredQueryData) + verify(sessionRepository, never()).markPendingEnrollment() + verify(sessionRepository, never()).clear() + } + @Test fun `clearPendingSessionIfNeeded should clear pending session when query is no longer biometric`() { whenever(sessionRepository.hasPendingSession()) doReturn true @@ -231,6 +347,35 @@ class SimprintsSearchViewModelTest { 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 @@ -272,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(), @@ -299,7 +444,7 @@ class SimprintsSearchViewModelTest { fun `onConfirmIdentityLaunchFailed should clear pending dashboard navigation`() = 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(), @@ -543,7 +688,8 @@ class SimprintsSearchViewModelTest { } @Test - fun `clearSimprintsBiometricQueryData should clear only biometric search items`() { + fun `clearSimprintsBiometricQueryData should keep session for sequential fallback`() { + whenever(sessionRepository.hasPendingSession()) doReturn true val viewModel = SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, @@ -569,7 +715,12 @@ class SimprintsSearchViewModelTest { assertEquals(mapOf("name" to listOf("Name")), queryData) assertEquals(null, updatedItems?.first()?.value) assertEquals("Name", updatedItems?.get(1)?.value) - verify(sessionRepository).clear() + assertTrue( + viewModel.shouldUseLastBiometricsLabel( + searchItems = listOf(textField(uid = "name", value = "Name")), + ), + ) + verify(sessionRepository, never()).clear() } @Test @@ -593,6 +744,42 @@ class SimprintsSearchViewModelTest { 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 { 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/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 { From f6fa875848615949b1e5554597af6ab974e223f3 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 4 May 2026 11:20:03 +0100 Subject: [PATCH 14/16] Simprints Search: atomic Consume pending MFID --- .../simprints/SimprintsSearchViewModel.kt | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index 267efa8c1f8..deb8cb82ac2 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -46,8 +46,8 @@ class SimprintsSearchViewModel( } private val pendingDashboardNavigation = AtomicReference(null) - private var pendingSimprintsMfidBiometricIdentification: PendingSimprintsMfidBiometricIdentification? = - null + private val pendingSimprintsMfidBiometricIdentification = + AtomicReference(null) private var useLastBiometricsForSequentialSearch = false private val _simprintsBiometricSearchNavigation = Channel(Channel.BUFFERED) val simprintsBiometricSearchNavigation: Flow = @@ -93,7 +93,7 @@ class SimprintsSearchViewModel( pendingDashboardNavigation.store(navigation) if (!keepSession) { - pendingSimprintsMfidBiometricIdentification = null + pendingSimprintsMfidBiometricIdentification.store(null) useLastBiometricsForSequentialSearch = false sessionRepository.clear() } @@ -135,7 +135,7 @@ class SimprintsSearchViewModel( fun clearPendingSession() { pendingDashboardNavigation.store(null) - pendingSimprintsMfidBiometricIdentification = null + pendingSimprintsMfidBiometricIdentification.store(null) useLastBiometricsForSequentialSearch = false sessionRepository.clear() } @@ -148,7 +148,7 @@ class SimprintsSearchViewModel( searchState.shouldClearPendingSession && !shouldUseSequentialSearchLastBiometrics(searchState) ) { - pendingSimprintsMfidBiometricIdentification = null + pendingSimprintsMfidBiometricIdentification.store(null) useLastBiometricsForSequentialSearch = false sessionRepository.clear() } @@ -173,12 +173,13 @@ class SimprintsSearchViewModel( hasAutoOpenEligibleSimprintsIdentification: Boolean, ) { useLastBiometricsForSequentialSearch = false - pendingSimprintsMfidBiometricIdentification = + pendingSimprintsMfidBiometricIdentification.store( if (!value.isNullOrBlank() && hasAutoOpenEligibleSimprintsIdentification) { PendingSimprintsMfidBiometricIdentification(uid = uid, value = value) } else { null - } + }, + ) } suspend fun onSimprintsParameterSaved( @@ -189,17 +190,6 @@ class SimprintsSearchViewModel( queryData: Map?>, ): PendingDashboardNavigation? { val searchFields = searchItems.toSearchFields() - val hasPendingMfidBiometricIdentification = - pendingSimprintsMfidBiometricIdentification?.let { - it.uid == uid && it.value == value - } == true - - if ( - !hasPendingMfidBiometricIdentification && - !shouldAutoNavigateToSimprintsBiometricSearch(uid, value, searchFields) - ) { - return null - } if (consumePendingSimprintsMfidBiometricIdentification(uid, value)) { return resolveSingleBiometricSearchNavigation( @@ -218,6 +208,10 @@ class SimprintsSearchViewModel( } ?: requestSimprintsBiometricSearchNavigation() } + if (!shouldAutoNavigateToSimprintsBiometricSearch(uid, value, searchFields)) { + return null + } + requestSimprintsBiometricSearchNavigation() return null } @@ -278,13 +272,13 @@ class SimprintsSearchViewModel( private fun consumePendingSimprintsMfidBiometricIdentification( uid: String, value: String?, - ): Boolean = - pendingSimprintsMfidBiometricIdentification - ?.takeIf { it.uid == uid && it.value == value } - ?.let { - pendingSimprintsMfidBiometricIdentification = null - true - } ?: false + ): Boolean { + val pending = pendingSimprintsMfidBiometricIdentification.load() + + return pending?.uid == uid && + pending.value == value && + pendingSimprintsMfidBiometricIdentification.compareAndSet(pending, null) + } private suspend fun requestSimprintsBiometricSearchNavigation(): PendingDashboardNavigation? { _simprintsBiometricSearchNavigation.send(Unit) From beb634b948a8a25c993eb913cb0f96fc1b069515 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 4 May 2026 12:09:11 +0100 Subject: [PATCH 15/16] Simprints custom intent persistence improvement --- .../enrollment/EnrollmentActivity.kt | 23 +++ .../CustomIntentActivityResultContract.kt | 181 +++++++++++++++--- .../inputfield/CustomIntentProvider.kt | 3 +- .../CustomIntentActivityResultContractTest.kt | 105 ++++++++-- 4 files changed, 267 insertions(+), 45 deletions(-) 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 e5d453e7d97..9c3b0e3c87c 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt @@ -29,6 +29,7 @@ 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 @@ -62,14 +63,18 @@ 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) { @@ -193,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, @@ -233,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 ) { @@ -264,11 +277,13 @@ 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) { @@ -326,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/form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt b/form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt index b11fda219b0..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 @@ -5,9 +5,9 @@ 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 @@ -17,9 +17,9 @@ 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" } @@ -30,8 +30,7 @@ class CustomIntentActivityResultContract : ActivityResultContract? val customIntentResponseParsedData = - customIntent?.let { - mapIntentResponseData(it.customIntentResponse, intent) - } + mapIntentResponseData(resultContext.responseData, intent) if (customIntentResponseParsedData.isNullOrEmpty()) { - mapPossibleSimprintsDuplicateResult(intent) - ?: 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 = ","), - action = customIntent?.packageName, + 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?): CustomIntentResult.PossibleDuplicates? { - val launchedCustomIntent = customIntent + private fun mapPossibleSimprintsDuplicateResult( + intent: Intent?, + resultContext: CustomIntentResultContext, + ): CustomIntentResult.PossibleDuplicates? { if ( - !SimprintsIntentUtils.isCallout(launchedCustomIntent) || - SimprintsIntentUtils.isIdentifyCallout(launchedCustomIntent) + !resultContext.isSimprintsCallout || + resultContext.isSimprintsIdentifyCallout ) { return null } @@ -90,7 +119,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, 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 d6e8a1b4fda..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 @@ -110,8 +110,9 @@ fun ProvideCustomIntentInput( } customIntentState = getCustomIntentState(values, fieldUiModel.isLoadingData) } + val customIntentActivityResultContract = remember { CustomIntentActivityResultContract() } val launcher = - rememberLauncherForActivityResult(contract = CustomIntentActivityResultContract()) { + rememberLauncherForActivityResult(contract = customIntentActivityResultContract) { when (it) { is CustomIntentResult.Error -> { customIntentState = CustomIntentState.LAUNCH 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 ed9937588e6..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 @@ -478,10 +478,11 @@ class CustomIntentActivityResultContractTest { @Test fun `parseResult should return possible duplicates for Simprints identifications`() { - prepareParseResult( - fieldUid = "field-uid", - customIntent = simprintsRegisterIntent(), - ) + val resultContext = + resultContext( + fieldUid = "field-uid", + customIntent = simprintsRegisterIntent(), + ) val bundleExtras = Bundle().apply { putString("sessionId", "session-id") @@ -496,7 +497,12 @@ class CustomIntentActivityResultContractTest { on { extras } doReturn bundleExtras } - val result = contract.parseResult(android.app.Activity.RESULT_OK, intent) + val result = + contract.parseResult( + resultCode = android.app.Activity.RESULT_OK, + intent = intent, + resultContext = resultContext, + ) assertTrue(result is CustomIntentResult.PossibleDuplicates) result as CustomIntentResult.PossibleDuplicates @@ -506,18 +512,78 @@ class CustomIntentActivityResultContractTest { @Test fun `parseResult should not treat Simprints identify result as possible duplicates`() { - prepareParseResult( - fieldUid = "field-uid", - customIntent = simprintsIdentifyIntent(), - ) + val resultContext = + resultContext( + fieldUid = "field-uid", + customIntent = simprintsIdentifyIntent(), + ) val intent = Intent().apply { putExtra("identifications", """[{"guid":"g1"}]""") } - val result = contract.parseResult(android.app.Activity.RESULT_OK, intent) + 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") @@ -540,17 +606,14 @@ class CustomIntentActivityResultContractTest { ), ) - private fun prepareParseResult( + private fun resultContext( fieldUid: String, customIntent: CustomIntentModel, - ) { - CustomIntentActivityResultContract::class.java - .getDeclaredField("fieldUid") - .apply { isAccessible = true } - .set(null, fieldUid) - CustomIntentActivityResultContract::class.java - .getDeclaredField("customIntent") - .apply { isAccessible = true } - .set(null, customIntent) - } + ) = CustomIntentResultContext.from( + CustomIntentInput( + fieldUid = fieldUid, + customIntent = customIntent, + defaultTitle = "default-title", + ), + ) } From 606702db594c05050b038523370b2f9efdfb197a Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 4 May 2026 19:21:41 +0100 Subject: [PATCH 16/16] Simprints biometric search falsely empty results fix --- .../searchTrackEntity/listView/SearchTEList.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 a70569fd614..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 @@ -357,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() } @@ -388,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)