From ab6943a578de7ad3fab88e9ab795f64b6a0eb2e5 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 6 Apr 2026 21:27:12 +0100 Subject: [PATCH 01/17] Simprints Confirm Identity & Enrol Last: features in dedicated files --- .../SimprintsCustomIntentResultMapper.kt | 18 +++ .../simprints/SimprintsEnrollmentViewModel.kt | 85 +++++++++++++ .../simprints/SimprintsSearchViewModel.kt | 109 ++++++++++++++++ .../repository/SimprintsD2Repository.kt | 76 ++++++++++++ .../repository/SimprintsSessionRepository.kt | 45 +++++++ ...ntsResolveConfirmIdentityCalloutUseCase.kt | 37 ++++++ ...tsResolvePendingEnrollmentActionUseCase.kt | 71 +++++++++++ .../simprints/utils/SimprintsIntentUtils.kt | 116 ++++++++++++++++++ .../simprints/utils/SimprintsSearchUtils.kt | 52 ++++++++ .../SimprintsCustomIntentFormPresenter.kt | 63 ++++++++++ ...printsRememberCustomIntentFormPresenter.kt | 48 ++++++++ 11 files changed, 720 insertions(+) create mode 100644 app/src/main/java/org/dhis2/simprints/SimprintsCustomIntentResultMapper.kt create mode 100644 app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt create mode 100644 app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt create mode 100644 form/src/main/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenter.kt create mode 100644 form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsCustomIntentResultMapper.kt b/app/src/main/java/org/dhis2/simprints/SimprintsCustomIntentResultMapper.kt new file mode 100644 index 00000000000..bdfb2ec59ce --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/SimprintsCustomIntentResultMapper.kt @@ -0,0 +1,18 @@ +package org.dhis2.simprints + +import android.content.Intent +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel + +class SimprintsCustomIntentResultMapper( + private val contract: CustomIntentActivityResultContract = CustomIntentActivityResultContract(), +) { + fun map( + responseData: List?, + data: Intent?, + ): String? = + contract + .mapIntentResponseData(responseData, data) + ?.takeUnless(List::isEmpty) + ?.joinToString(separator = ",") +} diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt new file mode 100644 index 00000000000..f7ed517ef82 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -0,0 +1,85 @@ +package org.dhis2.simprints + +import android.app.Activity.RESULT_OK +import android.content.Intent +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SimprintsEnrollmentViewModel( + private val simprintsD2Repository: SimprintsD2Repository, + private val resolvePendingEnrollmentAction: SimprintsResolvePendingEnrollmentActionUseCase, + private val sessionRepository: SimprintsSessionRepository, + private val resultMapper: SimprintsCustomIntentResultMapper, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + enum class RegisterLastResult { + NONE, + CONTINUE_FINISH, + ERROR, + } + + private var pendingAction: SimprintsResolvePendingEnrollmentActionUseCase.PendingEnrollmentAction? = null + + suspend fun onFinishRequested( + isNewEnrollment: Boolean, + enrollmentUid: String, + ): Intent? { + if (!isNewEnrollment) { + return null + } + + val resolvedAction = + sessionRepository.pendingEnrollmentSessionId()?.let { + resolvePendingEnrollmentAction( + enrollmentUid = enrollmentUid, + sessionId = it, + ) + } ?: return null + + pendingAction = resolvedAction + return resolvedAction.callout.launchIntent + } + + suspend fun onRegisterLastResult( + resultCode: Int, + data: Intent?, + teiUid: String?, + ): RegisterLastResult { + val resolvedAction = pendingAction ?: return RegisterLastResult.NONE + pendingAction = null + + val saved = + if (resultCode == RESULT_OK && teiUid != null) { + withContext(ioDispatcher) { + val value = + resultMapper.map( + responseData = resolvedAction.callout.responseData, + data = data, + ) ?: return@withContext false + simprintsD2Repository.saveTrackedEntityAttributeValue( + teiUid = teiUid, + attributeUid = resolvedAction.fieldUid, + value = value, + ) + true + } + } else { + false + } + + if (saved) { + sessionRepository.clear() + return RegisterLastResult.CONTINUE_FINISH + } + + return RegisterLastResult.ERROR + } + + fun onRegisterLastLaunchFailed() { + pendingAction = null + } +} diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt new file mode 100644 index 00000000000..4deaceda794 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -0,0 +1,109 @@ +package org.dhis2.simprints + +import android.app.Activity.RESULT_OK +import android.content.Intent +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase +import org.dhis2.commons.simprints.utils.SimprintsSearchUtils + +class SimprintsSearchViewModel( + private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase, + private val sessionRepository: SimprintsSessionRepository, +) { + data class PendingDashboardNavigation( + val teiUid: String, + val programUid: String?, + val enrollmentUid: String?, + ) + + sealed class DashboardAction { + data class LaunchConfirmIdentity( + val intent: Intent, + ) : DashboardAction() + + data class OpenDashboard( + val navigation: PendingDashboardNavigation, + ) : DashboardAction() + } + + private var pendingDashboardNavigation: PendingDashboardNavigation? = null + + suspend fun onDashboardRequested( + searchFields: List, + teiUid: String, + programUid: String?, + enrollmentUid: String?, + ): DashboardAction { + val searchState = SimprintsSearchUtils.searchState(searchFields) + val sessionId = + sessionRepository.get()?.takeIf { searchState.hasBiometricIdentificationQuery } + val confirmIdentityIntent = + sessionId?.let { + resolveConfirmIdentityCallout( + teiUid = teiUid, + searchFields = searchFields, + sessionId = it, + )?.launchIntent + } + + val navigation = + PendingDashboardNavigation( + teiUid = teiUid, + programUid = programUid, + enrollmentUid = enrollmentUid, + ) + if (confirmIdentityIntent == null) { + return DashboardAction.OpenDashboard(navigation) + } + + pendingDashboardNavigation = + navigation + sessionRepository.clear() + return DashboardAction.LaunchConfirmIdentity(confirmIdentityIntent) + } + + fun prepareEnrollmentQueryData( + searchFields: List, + queryData: Map?>, + ): HashMap> { + val searchState = SimprintsSearchUtils.searchState(searchFields) + + if (searchState.hasBiometricIdentificationQuery && sessionRepository.hasPendingSession()) { + sessionRepository.markPendingEnrollment() + } + + return SimprintsSearchUtils.filterQueryData( + queryData = queryData, + fields = searchFields, + ) + } + + fun onConfirmIdentityResult( + resultCode: Int, + ): PendingDashboardNavigation? = + pendingDashboardNavigation + ?.takeIf { resultCode == RESULT_OK } + .also { + pendingDashboardNavigation = null + } + + fun onConfirmIdentityLaunchFailed() { + pendingDashboardNavigation = null + } + + fun shouldUseLastBiometricsLabel( + searchFields: List, + ): Boolean { + val searchState = SimprintsSearchUtils.searchState(searchFields) + val hasPendingSession = sessionRepository.hasPendingSession() + if (hasPendingSession && searchState.shouldClearPendingSession) { + sessionRepository.clear() + return false + } + + return SimprintsSearchUtils.shouldUseLastBiometricsLabel( + searchState = searchState, + hasPendingSession = hasPendingSession, + ) + } +} diff --git a/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt new file mode 100644 index 00000000000..45404eaa049 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt @@ -0,0 +1,76 @@ +package org.dhis2.commons.simprints.repository + +import org.hisp.dhis.android.core.D2 + +class SimprintsD2Repository( + private val d2: D2, +) { + data class EnrollmentContext( + val teiUid: String, + val programUid: String?, + val orgUnitUid: String?, + ) + + fun getEnrollmentContext(enrollmentUid: String): EnrollmentContext? = + d2 + .enrollmentModule() + .enrollments() + .uid(enrollmentUid) + .blockingGet() + ?.let { enrollment -> + EnrollmentContext( + teiUid = enrollment.trackedEntityInstance() ?: return null, + programUid = enrollment.program(), + orgUnitUid = enrollment.organisationUnit(), + ) + } + + fun getProgramAttributeUids(programUid: String): List = + d2 + .programModule() + .programTrackedEntityAttributes() + .byProgram() + .eq(programUid) + .blockingGet() + .mapNotNull { it.trackedEntityAttribute()?.uid() } + + fun getTrackedEntityTypeAttributeUids(teiUid: String): List = + d2 + .trackedEntityModule() + .trackedEntityInstances() + .uid(teiUid) + .blockingGet() + ?.trackedEntityType() + ?.let { trackedEntityTypeUid -> + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(trackedEntityTypeUid) + .blockingGet() + .mapNotNull { it.trackedEntityAttribute()?.uid() } + } ?: emptyList() + + fun getTrackedEntityAttributeValue( + teiUid: String, + attributeUid: String, + ): String? = + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .value(attributeUid, teiUid) + .blockingGet() + ?.value() + + fun saveTrackedEntityAttributeValue( + teiUid: String, + attributeUid: String, + value: String, + ) { + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .value(attributeUid, teiUid) + .blockingSet(value) + } +} 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 new file mode 100644 index 00000000000..247de8e7dd7 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt @@ -0,0 +1,45 @@ +package org.dhis2.commons.simprints.repository + +import org.dhis2.commons.prefs.PreferenceProvider + +class SimprintsSessionRepository( + private val preferenceProvider: PreferenceProvider, +) { + fun save(sessionId: String) { + preferenceProvider.setValue(LAST_IDENTIFICATION_SESSION_ID, sessionId) + clearPendingEnrollment() + } + + fun get(): String? = + preferenceProvider.getString(LAST_IDENTIFICATION_SESSION_ID) + + fun hasPendingSession(): Boolean = + !get().isNullOrBlank() + + fun markPendingEnrollment() { + if (hasPendingSession()) { + preferenceProvider.setValue(PENDING_ENROLL_LAST, true) + } + } + + fun pendingEnrollmentSessionId(): String? = + get() + ?.takeIf(String::isNotBlank) + ?.takeIf { preferenceProvider.getBoolean(PENDING_ENROLL_LAST, false) } + + fun hasPendingEnrollment(): Boolean = + pendingEnrollmentSessionId() != null + + fun clearPendingEnrollment() = + preferenceProvider.removeValue(PENDING_ENROLL_LAST) + + fun clear() { + preferenceProvider.removeValue(LAST_IDENTIFICATION_SESSION_ID) + clearPendingEnrollment() + } + + 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" + } +} 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 new file mode 100644 index 00000000000..28cb4e25405 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt @@ -0,0 +1,37 @@ +package org.dhis2.commons.simprints.usecases + +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SimprintsResolveConfirmIdentityCalloutUseCase( + private val simprintsD2Repository: SimprintsD2Repository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + suspend operator fun invoke( + teiUid: String, + searchFields: Iterable, + sessionId: String, + ): SimprintsIntentUtils.PreparedCallout? = + withContext(ioDispatcher) { + searchFields.firstNotNullOfOrNull { field -> + val customIntent = field.customIntent + val selectedGuid = simprintsD2Repository.getTrackedEntityAttributeValue(teiUid, field.uid) + when { + field.value.isNullOrBlank() -> null + customIntent == null -> null + !SimprintsIntentUtils.isIdentifyCallout(customIntent) -> null + selectedGuid.isNullOrBlank() -> null + else -> + SimprintsIntentUtils.prepareConfirmIdentityCallout( + customIntent = customIntent, + sessionId = sessionId, + selectedGuid = selectedGuid, + ) + } + } + } +} diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt new file mode 100644 index 00000000000..547f9614a68 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt @@ -0,0 +1,71 @@ +package org.dhis2.commons.simprints.usecases + +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.dhis2.mobile.commons.customintents.CustomIntentRepository +import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel + +class SimprintsResolvePendingEnrollmentActionUseCase( + private val simprintsD2Repository: SimprintsD2Repository, + private val customIntentRepository: CustomIntentRepository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + data class PendingEnrollmentAction( + val fieldUid: String, + val callout: SimprintsIntentUtils.PreparedCallout, + ) + + suspend operator fun invoke( + enrollmentUid: String, + sessionId: String, + ): PendingEnrollmentAction? = + withContext(ioDispatcher) { + val enrollment = + simprintsD2Repository.getEnrollmentContext(enrollmentUid) + ?: return@withContext null + enrollment.attributeUids() + .firstNotNullOfOrNull { attributeUid -> + resolvePendingEnrollmentAction( + attributeUid = attributeUid, + teiUid = enrollment.teiUid, + orgUnitUid = enrollment.orgUnitUid, + sessionId = sessionId, + ) + } + } + + private fun SimprintsD2Repository.EnrollmentContext.attributeUids(): List = + buildList { + programUid?.let { addAll(simprintsD2Repository.getProgramAttributeUids(it)) } + addAll(simprintsD2Repository.getTrackedEntityTypeAttributeUids(teiUid)) + } + + private fun resolvePendingEnrollmentAction( + attributeUid: String, + teiUid: String, + orgUnitUid: String?, + sessionId: String, + ): PendingEnrollmentAction? { + val customIntent = customIntentRepository.getCustomIntent( + attributeUid, + orgUnitUid, + CustomIntentActionTypeModel.DATA_ENTRY, + ) ?: return null + if (!SimprintsIntentUtils.supportsRegisterLast(customIntent)) { + return null + } + if (!simprintsD2Repository.getTrackedEntityAttributeValue(teiUid, attributeUid) + .isNullOrBlank() + ) { + return null + } + + return PendingEnrollmentAction( + fieldUid = attributeUid, + callout = SimprintsIntentUtils.prepareRegisterLastCallout(customIntent, sessionId), + ) + } +} diff --git a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt new file mode 100644 index 00000000000..c1f5edf2f88 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt @@ -0,0 +1,116 @@ +package org.dhis2.commons.simprints.utils + +import android.content.Intent +import android.os.Bundle +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.mobile.commons.model.CustomIntentRequestArgumentModel +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel + +object SimprintsIntentUtils { + private const val SIMPRINTS_PACKAGE_NAME = "com.simprints.id" + private const val SIMPRINTS_IDENTIFY_ACTION = "$SIMPRINTS_PACKAGE_NAME.IDENTIFY" + private const val SIMPRINTS_CONFIRM_IDENTITY_ACTION = "$SIMPRINTS_PACKAGE_NAME.CONFIRM_IDENTITY" + private const val SIMPRINTS_REGISTER_LAST_ACTION = "$SIMPRINTS_PACKAGE_NAME.REGISTER_LAST_BIOMETRICS" + private const val SIMPRINTS_SESSION_ID_KEY = "sessionId" + private const val SIMPRINTS_SELECTED_GUID_KEY = "selectedGuid" + + data class PreparedCallout( + val launchIntent: Intent, + val responseData: List?, + ) + + fun isCallout(customIntent: CustomIntentModel?): Boolean = + customIntent?.packageName?.startsWith(SIMPRINTS_PACKAGE_NAME) == true + + fun isIdentifyCallout(customIntent: CustomIntentModel?): Boolean = + customIntent?.packageName == SIMPRINTS_IDENTIFY_ACTION + + fun supportsRegisterLast(customIntent: CustomIntentModel?): Boolean = + isCallout(customIntent) && !isIdentifyCallout(customIntent) + + fun extractSessionId(extras: Bundle?): String? = extras?.getString(SIMPRINTS_SESSION_ID_KEY) + + fun prepareCallout(customIntent: CustomIntentModel): PreparedCallout = + prepareCallout(customIntent, customIntent.packageName) + + fun prepareRegisterLastCallout( + customIntent: CustomIntentModel, + sessionId: String, + ): PreparedCallout = + prepareCallout( + customIntent = customIntent, + action = SIMPRINTS_REGISTER_LAST_ACTION, + requestArguments = arrayOf( + requestArgument(SIMPRINTS_SESSION_ID_KEY, sessionId), + ), + ) + + fun prepareConfirmIdentityCallout( + customIntent: CustomIntentModel, + sessionId: String, + selectedGuid: String, + ): PreparedCallout = + prepareCallout( + customIntent = customIntent, + action = SIMPRINTS_CONFIRM_IDENTITY_ACTION, + requestArguments = + arrayOf( + requestArgument(SIMPRINTS_SESSION_ID_KEY, sessionId), + requestArgument(SIMPRINTS_SELECTED_GUID_KEY, selectedGuid), + ), + ) + + fun hasPendingValue( + customIntent: CustomIntentModel?, + value: String?, + hasPendingEnrollment: Boolean, + ): Boolean = + value.isNullOrEmpty() && + supportsRegisterLast(customIntent) && + hasPendingEnrollment + + fun getDisplayValues( + value: String?, + hasPendingValue: Boolean, + placeholderValue: String, + ): List = + when { + !value.isNullOrEmpty() -> value.split(",") + hasPendingValue -> listOf(placeholderValue) + else -> emptyList() + } + + private fun prepareCallout( + customIntent: CustomIntentModel, + action: String, + requestArguments: Array = emptyArray(), + ): PreparedCallout = + PreparedCallout( + launchIntent = + Intent(action).apply { + customIntent.customIntentRequest + .filterNot { argument -> + argument.key in requestArguments.map { it.key } + } + .plus(requestArguments).forEach { argument -> + putRequestArgumentExtra(argument) + } + }, + responseData = customIntent.customIntentResponse, + ) + + private fun requestArgument(key: String, value: String): CustomIntentRequestArgumentModel = + CustomIntentRequestArgumentModel(key = key, value = value) + + private fun Intent.putRequestArgumentExtra(argument: CustomIntentRequestArgumentModel) { + when (val value = argument.value) { + is String -> putExtra(argument.key, value) + is Int -> putExtra(argument.key, value) + is Double -> putExtra(argument.key, value) + is Long -> putExtra(argument.key, value) + is Short -> putExtra(argument.key, value) + is Boolean -> putExtra(argument.key, value) + else -> putExtra(argument.key, value.toString()) + } + } +} diff --git a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt new file mode 100644 index 00000000000..b7c0035232a --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt @@ -0,0 +1,52 @@ +package org.dhis2.commons.simprints.utils + +import org.dhis2.mobile.commons.model.CustomIntentModel + +object SimprintsSearchUtils { + + data class SearchField( + val uid: String, + val value: String?, + val customIntent: CustomIntentModel? + ) + + data class SearchState( + val hasAnyQuery: Boolean, + val hasBiometricIdentificationQuery: Boolean, + ) { + val shouldClearPendingSession: Boolean + get() = hasAnyQuery && !hasBiometricIdentificationQuery + } + + fun shouldUseLastBiometricsLabel( + searchState: SearchState, + hasPendingSession: Boolean, + ): Boolean = searchState.hasBiometricIdentificationQuery && hasPendingSession + + fun filterQueryData( + queryData: Map?>, + fields: Iterable, + ): HashMap> { + val biometricFieldUids = + fields.mapNotNullTo(mutableSetOf()) { field -> + field.uid.takeIf { SimprintsIntentUtils.isIdentifyCallout(field.customIntent) } + } + + return queryData.entries.fold(HashMap()) { filteredQueryData, (uid, values) -> + if (uid !in biometricFieldUids && !values.isNullOrEmpty()) { + filteredQueryData[uid] = values + } + filteredQueryData + } + } + + fun searchState(fields: Iterable): SearchState { + val populatedFields = fields.filter { !it.value.isNullOrBlank() } + return SearchState( + hasAnyQuery = populatedFields.isNotEmpty(), + hasBiometricIdentificationQuery = + populatedFields.any { SimprintsIntentUtils.isIdentifyCallout(it.customIntent) }, + ) + } + +} diff --git a/form/src/main/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenter.kt b/form/src/main/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenter.kt new file mode 100644 index 00000000000..d4896525e22 --- /dev/null +++ b/form/src/main/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenter.kt @@ -0,0 +1,63 @@ +package org.dhis2.form.simprints + +import android.app.Activity +import android.content.Intent +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract + +class SimprintsCustomIntentFormPresenter internal constructor( + private val fieldValue: String?, + private val callout: SimprintsIntentUtils.PreparedCallout?, + private val capturesSessionId: Boolean, + private val sessionRepository: SimprintsSessionRepository, + private val contract: CustomIntentActivityResultContract, + private val placeholderValue: String, + val hasPendingValue: Boolean, +) { + val handlesLaunch: Boolean + get() = callout != null + + fun handleResult( + resultCode: Int, + data: Intent?, + ): String? { + if (resultCode != Activity.RESULT_OK) return null + val returnedValue = returnedValue(data) ?: return null + + if (capturesSessionId) { + SimprintsIntentUtils + .extractSessionId(data?.extras) + ?.let(sessionRepository::save) + } + + return returnedValue + } + + fun prepareLaunch() { + if (handlesLaunch) { + sessionRepository.clear() + } + } + + fun createLaunchIntent(): Intent? = callout?.launchIntent + + fun displayValues(): List = + SimprintsIntentUtils.getDisplayValues( + value = fieldValue, + hasPendingValue = hasPendingValue, + placeholderValue = placeholderValue, + ) + + fun clearPendingValue() { + if (hasPendingValue || handlesLaunch) { + sessionRepository.clear() + } + } + + private fun returnedValue(data: Intent?): String? = + contract + .mapIntentResponseData(callout?.responseData, data) + ?.takeUnless(List::isEmpty) + ?.joinToString(separator = ",") +} \ No newline at end of file diff --git a/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt b/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt new file mode 100644 index 00000000000..475eeb2aeed --- /dev/null +++ b/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt @@ -0,0 +1,48 @@ +package org.dhis2.form.simprints + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.form.R +import org.dhis2.form.model.FieldUiModel +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract + +@Composable +fun rememberSimprintsCustomIntentFormPresenter( + fieldUiModel: FieldUiModel, + resources: ResourceManager, + sessionRepository: SimprintsSessionRepository, +): SimprintsCustomIntentFormPresenter { + val customIntent = fieldUiModel.customIntent + val hasPendingValue = + SimprintsIntentUtils.hasPendingValue( + customIntent = customIntent, + value = fieldUiModel.value, + hasPendingEnrollment = sessionRepository.hasPendingEnrollment(), + ) + val callout = remember(customIntent) { + customIntent?.takeIf(SimprintsIntentUtils::isCallout) + ?.let(SimprintsIntentUtils::prepareCallout) + } + val placeholderValue = resources.getString(R.string.from_last_biometric_search) + + return remember( + fieldUiModel.value, + callout, + sessionRepository, + hasPendingValue, + placeholderValue, + ) { + SimprintsCustomIntentFormPresenter( + fieldValue = fieldUiModel.value, + callout = callout, + capturesSessionId = SimprintsIntentUtils.isIdentifyCallout(customIntent), + sessionRepository = sessionRepository, + contract = CustomIntentActivityResultContract(), + placeholderValue = placeholderValue, + hasPendingValue = hasPendingValue, + ) + } +} From 1b259b450bf99c6ecb01934bce9575c8666f3496 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 6 Apr 2026 21:27:53 +0100 Subject: [PATCH 02/17] Simprints Confirm Identity & Enrol Last: integration into existing code --- .../enrollment/EnrollmentActivity.kt | 62 +++++++++- .../usescases/enrollment/EnrollmentModule.kt | 44 +++++++ .../enrollment/EnrollmentPresenterImpl.kt | 26 ++++ .../searchTrackEntity/SearchTEActivity.kt | 52 +++++++- .../searchTrackEntity/SearchTEIViewModel.kt | 115 ++++++++++++++++++ .../searchTrackEntity/SearchTEModule.java | 50 +++++++- .../SearchTeiViewModelFactory.kt | 3 + .../listView/SearchTEList.kt | 4 + .../searchTrackEntity/ui/SearchTEUi.kt | 10 +- app/src/main/res/values/strings.xml | 1 + .../enrollment/EnrollmentPresenterImplTest.kt | 3 + .../SearchTEIViewModelTest.kt | 5 + .../org/dhis2/form/data/FormRepositoryImpl.kt | 17 ++- .../main/java/org/dhis2/form/di/Injector.kt | 4 + .../inputfield/CustomIntentProvider.kt | 69 +++++++++++ form/src/main/res/values/strings.xml | 1 + 16 files changed, 458 insertions(+), 8 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 78a76a02410..55c2664d632 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt @@ -7,9 +7,12 @@ import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.databinding.DataBindingUtil +import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch import org.dhis2.App import org.dhis2.R +import org.dhis2.R.string.custom_intent_error import org.dhis2.commons.Constants.ENROLLMENT_UID import org.dhis2.commons.Constants.PROGRAM_UID import org.dhis2.commons.Constants.TEI_UID @@ -30,8 +33,10 @@ import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventCaptureAc import org.dhis2.usescases.general.ActivityGlobalAbstract 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 import javax.inject.Inject class EnrollmentActivity : @@ -53,6 +58,36 @@ class EnrollmentActivity : lateinit var binding: EnrollmentActivityBinding lateinit var mode: EnrollmentMode + private val simprintsRegisterLastBiometricsLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + lifecycleScope.launch { + try { + when ( + presenter.onRegisterLastResult( + resultCode = result.resultCode, + data = result.data, + ) + ) { + RegisterLastResult.CONTINUE_FINISH -> { + presenter.finish(mode) + } + + RegisterLastResult.ERROR -> { + displayMessage(getString(custom_intent_error)) + formView.reload() + } + + RegisterLastResult.NONE -> { + // no-op + } + } + } catch (e: Exception) { + Timber.e(e) + displayMessage(getString(custom_intent_error)) + formView.reload() + } + } + } companion object { const val ENROLLMENT_UID_EXTRA = "ENROLLMENT_UID_EXTRA" @@ -133,7 +168,32 @@ class EnrollmentActivity : locationProvider = locationProvider, dateEditionWarningHandler = dateEditionWarningHandler, ) { - presenter.finish(enrollmentMode) + lifecycleScope.launch { + val simprintsRegisterLastIntent = + try { + presenter.onFinishRequested( + isNewEnrollment = enrollmentMode == EnrollmentMode.NEW, + enrollmentUid = enrollmentUid, + ) + } catch (e: Exception) { + Timber.e(e) + displayMessage(getString(custom_intent_error)) + formView.reload() + return@launch + } + if (simprintsRegisterLastIntent != null) { + try { + simprintsRegisterLastBiometricsLauncher.launch(simprintsRegisterLastIntent) + } catch (e: Exception) { + Timber.e(e) + presenter.onRegisterLastLaunchFailed() + displayMessage(getString(custom_intent_error)) + formView.reload() + } + return@launch + } + presenter.finish(enrollmentMode) + } } presenter.init() diff --git a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt index 53ecfd232e1..05cecef187f 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt @@ -14,6 +14,9 @@ import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisEnrollmentUtils @@ -41,6 +44,8 @@ import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.dhis2.mobile.commons.customintents.CustomIntentRepositoryImpl import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider import org.dhis2.mobile.commons.reporting.CrashReportController +import org.dhis2.simprints.SimprintsCustomIntentResultMapper +import org.dhis2.simprints.SimprintsEnrollmentViewModel import org.dhis2.usescases.teiDashboard.TeiAttributesProvider import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 @@ -147,6 +152,43 @@ class EnrollmentModule( @PerActivity fun provideCustomIntentProvider(d2: D2): CustomIntentRepository = CustomIntentRepositoryImpl(d2) + @Provides + @PerActivity + fun provideSimprintsSessionRepository(preferenceProvider: PreferenceProvider) = + SimprintsSessionRepository(preferenceProvider) + + @Provides + @PerActivity + fun provideSimprintsD2Repository(d2: D2) = SimprintsD2Repository(d2) + + @Provides + @PerActivity + fun provideSimprintsResolvePendingEnrollmentActionUseCase( + simprintsD2Repository: SimprintsD2Repository, + customIntentRepository: CustomIntentRepository, + ) = SimprintsResolvePendingEnrollmentActionUseCase( + simprintsD2Repository = simprintsD2Repository, + customIntentRepository = customIntentRepository, + ) + + @Provides + @PerActivity + fun provideSimprintsCustomIntentResultMapper() = SimprintsCustomIntentResultMapper() + + @Provides + @PerActivity + fun provideSimprintsEnrollmentViewModel( + simprintsD2Repository: SimprintsD2Repository, + resolvePendingEnrollmentAction: SimprintsResolvePendingEnrollmentActionUseCase, + simprintsSessionRepository: SimprintsSessionRepository, + simprintsCustomIntentResultMapper: SimprintsCustomIntentResultMapper, + ) = SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = simprintsSessionRepository, + resultMapper = simprintsCustomIntentResultMapper, + ) + @Provides @PerActivity fun providePresenter( @@ -161,6 +203,7 @@ class EnrollmentModule( eventCollectionRepository: EventCollectionRepository, teiAttributesProvider: TeiAttributesProvider, dateEditionWarningHandler: DateEditionWarningHandler, + simprintsEnrollmentViewModel: SimprintsEnrollmentViewModel, ): EnrollmentPresenterImpl = EnrollmentPresenterImpl( enrollmentView, @@ -175,6 +218,7 @@ class EnrollmentModule( eventCollectionRepository, teiAttributesProvider, dateEditionWarningHandler, + simprintsEnrollmentViewModel, ) @Provides 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 4561b451ce5..20c09a508f6 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt @@ -1,5 +1,6 @@ package org.dhis2.usescases.enrollment +import android.content.Intent import io.reactivex.disposables.CompositeDisposable import io.reactivex.processors.FlowableProcessor import io.reactivex.processors.PublishProcessor @@ -14,6 +15,7 @@ import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.schedulers.defaultSubscribe import org.dhis2.form.model.RowAction +import org.dhis2.simprints.SimprintsEnrollmentViewModel import org.dhis2.usescases.teiDashboard.TeiAttributesProvider import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.DELETE_AND_BACK @@ -50,6 +52,7 @@ class EnrollmentPresenterImpl( private val eventCollectionRepository: EventCollectionRepository, private val teiAttributesProvider: TeiAttributesProvider, private val dateEditionWarningHandler: DateEditionWarningHandler, + private val simprintsEnrollmentViewModel: SimprintsEnrollmentViewModel, ) { private val disposable = CompositeDisposable() private val backButtonProcessor: FlowableProcessor = PublishProcessor.create() @@ -157,6 +160,29 @@ class EnrollmentPresenterImpl( } } + suspend fun onFinishRequested( + isNewEnrollment: Boolean, + enrollmentUid: String, + ): Intent? = + simprintsEnrollmentViewModel.onFinishRequested( + isNewEnrollment = isNewEnrollment, + enrollmentUid = enrollmentUid, + ) + + suspend fun onRegisterLastResult( + resultCode: Int, + data: Intent?, + ): SimprintsEnrollmentViewModel.RegisterLastResult = + simprintsEnrollmentViewModel.onRegisterLastResult( + resultCode = resultCode, + data = data, + teiUid = getEnrollment()?.trackedEntityInstance(), + ) + + fun onRegisterLastLaunchFailed() { + simprintsEnrollmentViewModel.onRegisterLastLaunchFailed() + } + fun updateFields(action: RowAction? = null) { action?.let { dateEditionWarningHandler.shouldShowWarning(fieldUid = it.id) { message -> 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 87885c681dc..67cdbb9410c 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi @@ -124,6 +125,11 @@ class SearchTEActivity : private val viewModel: SearchTEIViewModel by viewModels { viewModelFactory } + private val simprintsConfirmIdentityLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.onConfirmIdentityResult(result.resultCode) + } + private var initSearchNeeded = true var searchComponent: SearchTEComponent? = null @@ -205,6 +211,7 @@ class SearchTEActivity : observeScreenState() observeDownload() observeLegacyInteractions() + observeSimprintsNavigation() if (intent.shouldLaunchSyncDialog()) { openSyncDialog() @@ -241,6 +248,7 @@ class SearchTEActivity : super.onResume() if (sessionManagerServiceImpl.isUserLoggedIn()) { FilterManager.getInstance().clearUnsupportedFilters() + viewModel.refreshSimprintsUiState() if (initSearchNeeded) { presenter.init() } else { @@ -553,7 +561,7 @@ class SearchTEActivity : when (legacyInteraction.id) { LegacyInteractionID.ON_ENROLL_CLICK -> { val interaction = legacyInteraction as OnEnrollClick - presenter.onEnrollClick(HashMap(interaction.queryData)) + presenter.onEnrollClick(viewModel.prepareEnrollmentQueryData(interaction.queryData)) } LegacyInteractionID.ON_ADD_RELATIONSHIP -> { @@ -575,7 +583,7 @@ class SearchTEActivity : presenter.enroll( interaction.initialProgramUid, interaction.teiUid, - HashMap(interaction.queryData), + viewModel.prepareEnrollmentQueryData(interaction.queryData), ) } @@ -593,6 +601,38 @@ class SearchTEActivity : } } + private fun observeSimprintsNavigation() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.simprintsNavigation.collect { action -> + when (action) { + is SimprintsNavigationAction.LaunchConfirmIdentity -> { + try { + simprintsConfirmIdentityLauncher.launch(action.intent) + } catch (e: Exception) { + Timber.e(e) + viewModel.onConfirmIdentityLaunchFailed() + displayMessage(getString(R.string.custom_intent_error)) + } + } + + is SimprintsNavigationAction.OpenDashboard -> { + openDashboardDirectly( + action.teiUid, + action.programUid, + action.enrollmentUid, + ) + } + + is SimprintsNavigationAction.ShowMessage -> { + displayMessage(action.message) + } + } + } + } + } + } + private fun observeMapLoading() { viewModel.refreshData.observe(this) { if (currentContent == Content.MAP) { @@ -724,6 +764,14 @@ class SearchTEActivity : teiUid: String, programUid: String?, enrollmentUid: String?, + ) { + viewModel.onOpenDashboardRequested(teiUid, programUid, enrollmentUid) + } + + private fun openDashboardDirectly( + teiUid: String, + programUid: String?, + enrollmentUid: String?, ) { searchNavigator.openDashboard(teiUid, programUid, enrollmentUid) } 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 3d645b7f686..3126a6ade17 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -1,5 +1,6 @@ package org.dhis2.usescases.searchTrackEntity +import android.content.Intent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.outlined.List @@ -42,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.SimprintsSearchUtils import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel import org.dhis2.form.model.FieldUiModelImpl @@ -54,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.SimprintsSearchViewModel import org.dhis2.usescases.searchTrackEntity.listView.SearchResult import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState import org.dhis2.usescases.searchTrackEntity.ui.UnableToSearchOutsideData @@ -68,6 +71,22 @@ import timber.log.Timber const val TEI_TYPE_SEARCH_MAX_RESULTS = 5 +sealed class SimprintsNavigationAction { + data class LaunchConfirmIdentity( + val intent: Intent, + ) : SimprintsNavigationAction() + + data class OpenDashboard( + val teiUid: String, + val programUid: String?, + val enrollmentUid: String?, + ) : SimprintsNavigationAction() + + data class ShowMessage( + val message: String, + ) : SimprintsNavigationAction() +} + class SearchTEIViewModel( val initialProgramUid: String?, initialQuery: MutableMap?>?, @@ -81,6 +100,7 @@ class SearchTEIViewModel( private val resourceManager: ResourceManager, private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, + private val simprintsSearchViewModel: SimprintsSearchViewModel, ) : ViewModel() { private var layersVisibility: Map = emptyMap() @@ -110,11 +130,16 @@ class SearchTEIViewModel( private val _mapItemClicked = MutableSharedFlow() val mapItemClicked: Flow = _mapItemClicked + private val _simprintsNavigation = Channel() + val simprintsNavigation: Flow = _simprintsNavigation.receiveAsFlow() + private val _screenState = MutableLiveData() val screenState: LiveData = _screenState val createButtonScrollVisibility = MutableLiveData(false) val isScrollingDown = MutableLiveData(false) + private val _isSimprintsUseLastBiometricsLabel = MutableLiveData(false) + val isSimprintsUseLastBiometricsLabel: LiveData = _isSimprintsUseLastBiometricsLabel private var searching: Boolean = false private val filtersActive = MutableLiveData(false) @@ -172,6 +197,7 @@ class SearchTEIViewModel( searchRepository.trackedEntityType.displayName(), ) } + refreshSimprintsUiState() } private fun loadNavigationBarItems() { @@ -412,6 +438,7 @@ class SearchTEIViewModel( } } searchParametersUiState = searchParametersUiState.copy(items = updatedItems) + refreshSimprintsUiState() } fun clearQueryData() { @@ -432,6 +459,7 @@ class SearchTEIViewModel( searchedItems = mapOf(), ) searching = false + refreshSimprintsUiState() } private fun updateSearch() { @@ -665,6 +693,92 @@ class SearchTEIViewModel( fun queryDataByProgram(programUid: String?): MutableMap> = searchRepository.filterQueryForProgram(queryData, programUid) + fun prepareEnrollmentQueryData(queryData: Map?>): HashMap> = + simprintsSearchViewModel.prepareEnrollmentQueryData( + searchFields = getSimprintsSearchFields(), + queryData = queryData, + ) + + fun onOpenDashboardRequested( + teiUid: String, + programUid: String?, + enrollmentUid: String?, + ) { + viewModelScope.launch { + try { + when ( + val action = + simprintsSearchViewModel.onDashboardRequested( + searchFields = getSimprintsSearchFields(), + teiUid = teiUid, + programUid = programUid, + enrollmentUid = enrollmentUid, + ) + ) { + is SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity -> { + _simprintsNavigation.send( + SimprintsNavigationAction.LaunchConfirmIdentity(action.intent), + ) + } + + is SimprintsSearchViewModel.DashboardAction.OpenDashboard -> { + _simprintsNavigation.send( + SimprintsNavigationAction.OpenDashboard( + teiUid = action.navigation.teiUid, + programUid = action.navigation.programUid, + enrollmentUid = action.navigation.enrollmentUid, + ), + ) + } + } + refreshSimprintsUiState() + } catch (e: Exception) { + Timber.e(e) + _simprintsNavigation.send( + SimprintsNavigationAction.ShowMessage( + resourceManager.getString(R.string.custom_intent_error), + ), + ) + } + } + } + + fun onConfirmIdentityResult(resultCode: Int) { + simprintsSearchViewModel.onConfirmIdentityResult(resultCode)?.let { navigation -> + viewModelScope.launch { + _simprintsNavigation.send( + SimprintsNavigationAction.OpenDashboard( + teiUid = navigation.teiUid, + programUid = navigation.programUid, + enrollmentUid = navigation.enrollmentUid, + ), + ) + } + } + refreshSimprintsUiState() + } + + fun onConfirmIdentityLaunchFailed() { + simprintsSearchViewModel.onConfirmIdentityLaunchFailed() + } + + fun refreshSimprintsUiState() { + _isSimprintsUseLastBiometricsLabel.postValue( + simprintsSearchViewModel.shouldUseLastBiometricsLabel( + searchFields = getSimprintsSearchFields(), + ), + ) + } + + private fun getSimprintsSearchFields(): List = + searchParametersUiState.items.map { field -> + SimprintsSearchUtils.SearchField( + uid = field.uid, + value = field.value, + customIntent = field.customIntent, + ) + } + fun onEnrollClick() { _legacyInteraction.postValue(LegacyInteraction.OnEnrollClick(queryData)) } @@ -975,6 +1089,7 @@ class SearchTEIViewModel( val fieldUiModels = searchRepositoryKt.searchParameters(programUid, teiTypeUid) searchParametersUiState = searchParametersUiState.copy(items = fieldUiModels) + refreshSimprintsUiState() } } 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 2bebf70266c..394e93699ee 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -22,6 +22,9 @@ import org.dhis2.commons.resources.DhisPeriodUtils; import org.dhis2.commons.resources.MetadataIconProvider; import org.dhis2.commons.resources.ResourceManager; +import org.dhis2.commons.simprints.repository.SimprintsD2Repository; +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository; +import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase; import org.dhis2.commons.schedulers.SchedulerProvider; import org.dhis2.commons.viewmodel.DispatcherProvider; import org.dhis2.data.dhislogic.DhisEnrollmentUtils; @@ -64,6 +67,7 @@ import org.dhis2.mobile.commons.reporting.CrashReportController; import org.dhis2.tracker.data.ProfilePictureProvider; import org.dhis2.ui.ThemeManager; +import org.dhis2.simprints.SimprintsSearchViewModel; import org.dhis2.usescases.events.EventInfoProvider; import org.dhis2.usescases.searchTrackEntity.ui.mapper.TEICardMapper; import org.dhis2.usescases.tracker.TrackedEntityInstanceInfoProvider; @@ -316,6 +320,46 @@ FiltersAdapter provideNewFiltersAdapter() { return new FiltersAdapter(); } + @Provides + @PerActivity + SimprintsSessionRepository provideSimprintsSessionRepository( + PreferenceProvider preferenceProvider + ) { + return new SimprintsSessionRepository(preferenceProvider); + } + + @Provides + @PerActivity + SimprintsD2Repository provideSimprintsD2Repository( + D2 d2 + ) { + return new SimprintsD2Repository(d2); + } + + @Provides + @PerActivity + SimprintsResolveConfirmIdentityCalloutUseCase provideSimprintsResolveConfirmIdentityCalloutUseCase( + SimprintsD2Repository simprintsD2Repository, + DispatcherProvider dispatcherProvider + ) { + return new SimprintsResolveConfirmIdentityCalloutUseCase( + simprintsD2Repository, + dispatcherProvider.io() + ); + } + + @Provides + @PerActivity + SimprintsSearchViewModel provideSimprintsSearchViewModel( + SimprintsResolveConfirmIdentityCalloutUseCase resolveConfirmIdentityCalloutUseCase, + SimprintsSessionRepository simprintsSessionRepository + ) { + return new SimprintsSearchViewModel( + resolveConfirmIdentityCalloutUseCase, + simprintsSessionRepository + ); + } + @Provides @PerActivity SearchTeiViewModelFactory providesViewModelFactory( @@ -327,7 +371,8 @@ SearchTeiViewModelFactory providesViewModelFactory( ResourceManager resourceManager, DisplayNameProvider displayNameProvider, FilterManager filterManager, - ProgramConfigurationRepository programConfigurationRepository + ProgramConfigurationRepository programConfigurationRepository, + SimprintsSearchViewModel simprintsSearchViewModel ) { return new SearchTeiViewModelFactory( searchRepository, @@ -346,7 +391,8 @@ SearchTeiViewModelFactory providesViewModelFactory( ), resourceManager, displayNameProvider, - filterManager + filterManager, + simprintsSearchViewModel ); } 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 ee636e4696e..fe3a6f28997 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt @@ -8,6 +8,7 @@ import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.usecases.MapStyleConfiguration +import org.dhis2.simprints.SimprintsSearchViewModel class SearchTeiViewModelFactory( private val searchRepository: SearchRepository, @@ -22,6 +23,7 @@ class SearchTeiViewModelFactory( private val resourceManager: ResourceManager, private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, + private val simprintsSearchViewModel: SimprintsSearchViewModel, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = SearchTEIViewModel( @@ -37,5 +39,6 @@ class SearchTeiViewModelFactory( resourceManager, displayNameProvider, filterManager, + simprintsSearchViewModel, ) 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 b0193d2f4a3..b30ac0f1224 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 @@ -268,6 +268,9 @@ class SearchTEList : FragmentGlobalAbstract() { remember(viewModel.searchParametersUiState) { viewModel.queryData.isNotEmpty() } + val isSimprintsUseLastBiometricsLabel by viewModel + .isSimprintsUseLastBiometricsLabel + .observeAsState(false) updateLayoutParams { val bottomMargin = @@ -290,6 +293,7 @@ class SearchTEList : FragmentGlobalAbstract() { extended = !isScrollingDown, onClick = viewModel::onEnrollClick, teTypeName = teTypeName!!, + isSimprintsUseLastBiometricsLabel = isSimprintsUseLastBiometricsLabel, ) } } 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 7ba9d943554..2ce64ad1ffd 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 @@ -644,6 +644,7 @@ fun CreateNewButton( teTypeName: String, modifier: Modifier = Modifier, extended: Boolean = true, + isSimprintsUseLastBiometricsLabel: Boolean = false, onClick: () -> Unit, ) { val icon = @Composable { @@ -663,7 +664,14 @@ fun CreateNewButton( ExtendedFAB( modifier = modifier, onClick = onClick, - text = stringResource(R.string.search_new_te_type, teTypeName.lowercase()), + text = stringResource( + if (isSimprintsUseLastBiometricsLabel) { + R.string.search_new_te_type_with_last_biometrics + } else { + R.string.search_new_te_type + }, + teTypeName.lowercase(), + ), icon = icon, style = FABStyle.SECONDARY, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43c1d6df602..3a39fdfb0e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -858,6 +858,7 @@ Try changing your search to narrow down the results. Create new New %s + New %s with last biometrics It is necessary to enter at least %s attributes to search Ok Start a search to find any %s 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 5b5233bf151..709a3049611 100644 --- a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt @@ -5,6 +5,7 @@ import io.reactivex.processors.PublishProcessor import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider +import org.dhis2.simprints.SimprintsEnrollmentViewModel import org.dhis2.usescases.enrollment.EnrollmentActivity.EnrollmentMode.CHECK import org.dhis2.usescases.enrollment.EnrollmentActivity.EnrollmentMode.NEW import org.dhis2.usescases.teiDashboard.TeiAttributesProvider @@ -47,6 +48,7 @@ class EnrollmentPresenterImplTest { private val eventCollectionRepository: EventCollectionRepository = mock() private val teiAttributesProvider: TeiAttributesProvider = mock() private val dateEntryWarningHelper: DateEditionWarningHandler = mock() + private val simprintsEnrollmentViewModel: SimprintsEnrollmentViewModel = mock() @Before fun setUp() { @@ -64,6 +66,7 @@ class EnrollmentPresenterImplTest { eventCollectionRepository, teiAttributesProvider, dateEntryWarningHelper, + simprintsEnrollmentViewModel, ) } 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 546d952cbd0..89b99b25895 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -30,6 +30,7 @@ import org.dhis2.form.ui.intent.FormIntent import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.geometry.mapper.EventsByProgramStage import org.dhis2.maps.usecases.MapStyleConfiguration +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 @@ -70,6 +71,7 @@ class SearchTEIViewModelTest { private val resourceManager: ResourceManager = mock() private val displayNameProvider: DisplayNameProvider = mock() private val filterManager: FilterManager = mock() + private val simprintsSearchViewModel: SimprintsSearchViewModel = mock() @ExperimentalCoroutinesApi private val testingDispatcher = StandardTestDispatcher() @@ -103,6 +105,7 @@ class SearchTEIViewModelTest { resourceManager = resourceManager, displayNameProvider = displayNameProvider, filterManager = filterManager, + simprintsSearchViewModel = simprintsSearchViewModel, ) testingDispatcher.scheduler.advanceUntilIdle() } @@ -792,6 +795,7 @@ class SearchTEIViewModelTest { resourceManager = resourceManager, displayNameProvider = displayNameProvider, filterManager = filterManager, + simprintsSearchViewModel = simprintsSearchViewModel, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -838,6 +842,7 @@ class SearchTEIViewModelTest { }, displayNameProvider = displayNameProvider, filterManager = filterManager, + simprintsSearchViewModel = simprintsSearchViewModel, ) testingDispatcher.scheduler.advanceUntilIdle() diff --git a/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt b/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt index fe05870549d..fb2a70f310d 100644 --- a/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt +++ b/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt @@ -7,6 +7,8 @@ import org.dhis2.commons.dialogs.bottomsheet.IssueType import org.dhis2.commons.periods.model.Period import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.form.data.EnrollmentRepository.Companion.ENROLLMENT_DATE_UID import org.dhis2.form.model.ActionType import org.dhis2.form.model.FieldUiModel @@ -54,6 +56,15 @@ class FormRepositoryImpl( private val disableCollapsableSections: Boolean? = dataEntryRepository.disableCollapsableSections() + private val simprintsSessionRepository = SimprintsSessionRepository(preferenceProvider) + + private fun hasPendingSimprintsRegisterLastValue(fieldUiModel: FieldUiModel): Boolean = + SimprintsIntentUtils.hasPendingValue( + customIntent = fieldUiModel.customIntent, + value = fieldUiModel.value, + hasPendingEnrollment = simprintsSessionRepository.hasPendingEnrollment(), + ) + override suspend fun fetchFormItems(shouldOpenErrorLocation: Boolean): List { itemList = dataEntryRepository.list().blockingFirst() ?: emptyList() openedSectionUid = getInitialOpenedSection(shouldOpenErrorLocation) @@ -144,7 +155,8 @@ class FormRepositoryImpl( !unsupportedValueTypes.contains(it.valueType) } val totalFields = fields.size - val fieldsWithValue = fields.filter { !it.value.isNullOrEmpty() }.size + val fieldsWithValue = + fields.filter { !it.value.isNullOrEmpty() || hasPendingSimprintsRegisterLastValue(it) }.size completionPercentage = if (totalFields == 0) { 0f @@ -580,7 +592,7 @@ class FormRepositoryImpl( it.programStageSection.equals(sectionFieldUiModel.uid) && it.valueType != null }.forEach { total++ - if (!it.value.isNullOrEmpty()) { + if (!it.value.isNullOrEmpty() || hasPendingSimprintsRegisterLastValue(it)) { values++ } } @@ -653,6 +665,7 @@ class FormRepositoryImpl( ) } else { fieldUiModel.mandatory && + !hasPendingSimprintsRegisterLastValue(fieldUiModel) && fieldUiModel.value.isNullOrEmpty() } diff --git a/form/src/main/java/org/dhis2/form/di/Injector.kt b/form/src/main/java/org/dhis2/form/di/Injector.kt index e0e481eaae0..2076d7ee216 100644 --- a/form/src/main/java/org/dhis2/form/di/Injector.kt +++ b/form/src/main/java/org/dhis2/form/di/Injector.kt @@ -10,6 +10,7 @@ import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.data.DataEntryRepository import org.dhis2.form.data.EnrollmentRepository @@ -264,6 +265,9 @@ object Injector { private fun providePreferenceProvider(context: Context) = PreferenceProviderImpl(context) + fun provideSimprintsSessionRepository(context: Context) = + SimprintsSessionRepository(providePreferenceProvider(context)) + private fun provideRuleEngineRepository( entryMode: EntryMode, recordUid: 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 049b901e3df..a2d3ea03702 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,7 +1,10 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -9,11 +12,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import org.dhis2.commons.resources.ResourceManager 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.rememberSimprintsCustomIntentFormPresenter import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract import org.dhis2.form.ui.customintent.CustomIntentInput import org.dhis2.form.ui.customintent.CustomIntentResult @@ -36,6 +42,7 @@ fun ProvideCustomIntentInput( reEvaluateRequestParams: Boolean, modifier: Modifier, ) { + val context = LocalContext.current.applicationContext val values = remember(fieldUiModel) { fieldUiModel.value?.takeIf { it.isNotEmpty() }?.let { value -> @@ -62,6 +69,52 @@ fun ProvideCustomIntentInput( customIntentState = CustomIntentState.LAUNCH if (!supportingTextList.contains(fieldErrorMessage)) supportingTextList.add(fieldErrorMessage) } + val simprintsSessionRepository = + remember(context) { + Injector.provideSimprintsSessionRepository(context) + } + val simprintsCustomIntentFormPresenter = + rememberSimprintsCustomIntentFormPresenter( + fieldUiModel = fieldUiModel, + resources = resources, + sessionRepository = simprintsSessionRepository, + ) + LaunchedEffect( + fieldUiModel.value, + fieldUiModel.isLoadingData, + simprintsCustomIntentFormPresenter.hasPendingValue, + ) { + val displayValues = simprintsCustomIntentFormPresenter.displayValues() + if (values != displayValues) { + values.clear() + values.addAll(displayValues) + } + customIntentState = getCustomIntentState(values, fieldUiModel.isLoadingData) + } + val simprintsLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> + val returnedValue = + simprintsCustomIntentFormPresenter.handleResult(result.resultCode, result.data) + + if (result.resultCode != RESULT_OK || returnedValue == null) { + customIntentState = CustomIntentState.LAUNCH + inputShellState = InputShellState.ERROR + if (!supportingTextList.contains(errorGettingDataMessage)) { + supportingTextList.add( + errorGettingDataMessage, + ) + } + } else { + customIntentState = CustomIntentState.LOADED + intentHandler( + FormIntent.OnSave( + fieldUiModel.uid, + returnedValue, + fieldUiModel.valueType, + ), + ) + } + } val launcher = rememberLauncherForActivityResult(contract = CustomIntentActivityResultContract()) { when (it) { @@ -95,6 +148,21 @@ fun ProvideCustomIntentInput( modifier = modifier, isRequired = fieldUiModel.mandatory, onLaunch = { + if (simprintsCustomIntentFormPresenter.hasPendingValue) { + return@InputCustomIntent + } + simprintsCustomIntentFormPresenter.prepareLaunch() + + if (!reEvaluateRequestParams && simprintsCustomIntentFormPresenter.handlesLaunch) { + customIntentState = CustomIntentState.LOADING + if (supportingTextList.contains(errorGettingDataMessage)) { + supportingTextList.remove(errorGettingDataMessage) + } + simprintsCustomIntentFormPresenter.createLaunchIntent() + ?.let(simprintsLauncher::launch) + return@InputCustomIntent + } + if (reEvaluateRequestParams) { customIntentState = CustomIntentState.LOADING uiEventHandler.invoke( @@ -120,6 +188,7 @@ fun ProvideCustomIntentInput( } }, onClear = { + simprintsCustomIntentFormPresenter.clearPendingValue() values.clear() intentHandler(FormIntent.ClearValue(fieldUiModel.uid)) }, diff --git a/form/src/main/res/values/strings.xml b/form/src/main/res/values/strings.xml index dbb9e7fa1de..5b98873d441 100644 --- a/form/src/main/res/values/strings.xml +++ b/form/src/main/res/values/strings.xml @@ -92,6 +92,7 @@ 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) Launch \ No newline at end of file From 7a7516416f9feb664c7e97c33b3350eca75c598d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Apr 2026 12:32:28 +0100 Subject: [PATCH 03/17] Simprints Confirm Identity & Enrol Last: ktlint on added files --- .../simprints/SimprintsEnrollmentViewModel.kt | 6 ++-- .../simprints/SimprintsSearchViewModel.kt | 8 ++--- .../repository/SimprintsSessionRepository.kt | 12 +++---- ...ntsResolveConfirmIdentityCalloutUseCase.kt | 6 ++-- ...tsResolvePendingEnrollmentActionUseCase.kt | 21 +++++++----- .../simprints/utils/SimprintsIntentUtils.kt | 33 +++++++++---------- .../simprints/utils/SimprintsSearchUtils.kt | 4 +-- ...printsRememberCustomIntentFormPresenter.kt | 10 +++--- 8 files changed, 47 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index f7ed517ef82..26616a51c19 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -2,12 +2,12 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent -import org.dhis2.commons.simprints.repository.SimprintsD2Repository -import org.dhis2.commons.simprints.repository.SimprintsSessionRepository -import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase class SimprintsEnrollmentViewModel( private val simprintsD2Repository: SimprintsD2Repository, diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index 4deaceda794..a41b1c06d36 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -78,9 +78,7 @@ class SimprintsSearchViewModel( ) } - fun onConfirmIdentityResult( - resultCode: Int, - ): PendingDashboardNavigation? = + fun onConfirmIdentityResult(resultCode: Int): PendingDashboardNavigation? = pendingDashboardNavigation ?.takeIf { resultCode == RESULT_OK } .also { @@ -91,9 +89,7 @@ class SimprintsSearchViewModel( pendingDashboardNavigation = null } - fun shouldUseLastBiometricsLabel( - searchFields: List, - ): Boolean { + fun shouldUseLastBiometricsLabel(searchFields: List): Boolean { val searchState = SimprintsSearchUtils.searchState(searchFields) val hasPendingSession = sessionRepository.hasPendingSession() if (hasPendingSession && searchState.shouldClearPendingSession) { 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 247de8e7dd7..01d8e431467 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 @@ -10,11 +10,9 @@ class SimprintsSessionRepository( clearPendingEnrollment() } - fun get(): String? = - preferenceProvider.getString(LAST_IDENTIFICATION_SESSION_ID) + fun get(): String? = preferenceProvider.getString(LAST_IDENTIFICATION_SESSION_ID) - fun hasPendingSession(): Boolean = - !get().isNullOrBlank() + fun hasPendingSession(): Boolean = !get().isNullOrBlank() fun markPendingEnrollment() { if (hasPendingSession()) { @@ -27,11 +25,9 @@ class SimprintsSessionRepository( ?.takeIf(String::isNotBlank) ?.takeIf { preferenceProvider.getBoolean(PENDING_ENROLL_LAST, false) } - fun hasPendingEnrollment(): Boolean = - pendingEnrollmentSessionId() != null + fun hasPendingEnrollment(): Boolean = pendingEnrollmentSessionId() != null - fun clearPendingEnrollment() = - preferenceProvider.removeValue(PENDING_ENROLL_LAST) + fun clearPendingEnrollment() = preferenceProvider.removeValue(PENDING_ENROLL_LAST) fun clear() { preferenceProvider.removeValue(LAST_IDENTIFICATION_SESSION_ID) 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 28cb4e25405..8ac81155d7f 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 @@ -1,11 +1,11 @@ package org.dhis2.commons.simprints.usecases -import org.dhis2.commons.simprints.repository.SimprintsD2Repository -import org.dhis2.commons.simprints.utils.SimprintsIntentUtils -import org.dhis2.commons.simprints.utils.SimprintsSearchUtils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.commons.simprints.utils.SimprintsSearchUtils class SimprintsResolveConfirmIdentityCalloutUseCase( private val simprintsD2Repository: SimprintsD2Repository, diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt index 547f9614a68..438ef43e947 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt @@ -1,10 +1,10 @@ package org.dhis2.commons.simprints.usecases -import org.dhis2.commons.simprints.repository.SimprintsD2Repository -import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel @@ -26,7 +26,8 @@ class SimprintsResolvePendingEnrollmentActionUseCase( val enrollment = simprintsD2Repository.getEnrollmentContext(enrollmentUid) ?: return@withContext null - enrollment.attributeUids() + enrollment + .attributeUids() .firstNotNullOfOrNull { attributeUid -> resolvePendingEnrollmentAction( attributeUid = attributeUid, @@ -49,15 +50,17 @@ class SimprintsResolvePendingEnrollmentActionUseCase( orgUnitUid: String?, sessionId: String, ): PendingEnrollmentAction? { - val customIntent = customIntentRepository.getCustomIntent( - attributeUid, - orgUnitUid, - CustomIntentActionTypeModel.DATA_ENTRY, - ) ?: return null + val customIntent = + customIntentRepository.getCustomIntent( + attributeUid, + orgUnitUid, + CustomIntentActionTypeModel.DATA_ENTRY, + ) ?: return null if (!SimprintsIntentUtils.supportsRegisterLast(customIntent)) { return null } - if (!simprintsD2Repository.getTrackedEntityAttributeValue(teiUid, attributeUid) + if (!simprintsD2Repository + .getTrackedEntityAttributeValue(teiUid, attributeUid) .isNullOrBlank() ) { return null diff --git a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt index c1f5edf2f88..f1042d4ff88 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt @@ -19,19 +19,15 @@ object SimprintsIntentUtils { val responseData: List?, ) - fun isCallout(customIntent: CustomIntentModel?): Boolean = - customIntent?.packageName?.startsWith(SIMPRINTS_PACKAGE_NAME) == true + fun isCallout(customIntent: CustomIntentModel?): Boolean = customIntent?.packageName?.startsWith(SIMPRINTS_PACKAGE_NAME) == true - fun isIdentifyCallout(customIntent: CustomIntentModel?): Boolean = - customIntent?.packageName == SIMPRINTS_IDENTIFY_ACTION + fun isIdentifyCallout(customIntent: CustomIntentModel?): Boolean = customIntent?.packageName == SIMPRINTS_IDENTIFY_ACTION - fun supportsRegisterLast(customIntent: CustomIntentModel?): Boolean = - isCallout(customIntent) && !isIdentifyCallout(customIntent) + fun supportsRegisterLast(customIntent: CustomIntentModel?): Boolean = isCallout(customIntent) && !isIdentifyCallout(customIntent) fun extractSessionId(extras: Bundle?): String? = extras?.getString(SIMPRINTS_SESSION_ID_KEY) - fun prepareCallout(customIntent: CustomIntentModel): PreparedCallout = - prepareCallout(customIntent, customIntent.packageName) + fun prepareCallout(customIntent: CustomIntentModel): PreparedCallout = prepareCallout(customIntent, customIntent.packageName) fun prepareRegisterLastCallout( customIntent: CustomIntentModel, @@ -40,9 +36,10 @@ object SimprintsIntentUtils { prepareCallout( customIntent = customIntent, action = SIMPRINTS_REGISTER_LAST_ACTION, - requestArguments = arrayOf( - requestArgument(SIMPRINTS_SESSION_ID_KEY, sessionId), - ), + requestArguments = + arrayOf( + requestArgument(SIMPRINTS_SESSION_ID_KEY, sessionId), + ), ) fun prepareConfirmIdentityCallout( @@ -66,8 +63,8 @@ object SimprintsIntentUtils { hasPendingEnrollment: Boolean, ): Boolean = value.isNullOrEmpty() && - supportsRegisterLast(customIntent) && - hasPendingEnrollment + supportsRegisterLast(customIntent) && + hasPendingEnrollment fun getDisplayValues( value: String?, @@ -91,16 +88,18 @@ object SimprintsIntentUtils { customIntent.customIntentRequest .filterNot { argument -> argument.key in requestArguments.map { it.key } - } - .plus(requestArguments).forEach { argument -> + }.plus(requestArguments) + .forEach { argument -> putRequestArgumentExtra(argument) } }, responseData = customIntent.customIntentResponse, ) - private fun requestArgument(key: String, value: String): CustomIntentRequestArgumentModel = - CustomIntentRequestArgumentModel(key = key, value = value) + private fun requestArgument( + key: String, + value: String, + ): CustomIntentRequestArgumentModel = CustomIntentRequestArgumentModel(key = key, value = value) private fun Intent.putRequestArgumentExtra(argument: CustomIntentRequestArgumentModel) { when (val value = argument.value) { diff --git a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt index b7c0035232a..2c96b62266e 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt @@ -3,11 +3,10 @@ package org.dhis2.commons.simprints.utils import org.dhis2.mobile.commons.model.CustomIntentModel object SimprintsSearchUtils { - data class SearchField( val uid: String, val value: String?, - val customIntent: CustomIntentModel? + val customIntent: CustomIntentModel?, ) data class SearchState( @@ -48,5 +47,4 @@ object SimprintsSearchUtils { populatedFields.any { SimprintsIntentUtils.isIdentifyCallout(it.customIntent) }, ) } - } 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 475eeb2aeed..7e67ad43a69 100644 --- a/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt +++ b/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt @@ -22,10 +22,12 @@ fun rememberSimprintsCustomIntentFormPresenter( value = fieldUiModel.value, hasPendingEnrollment = sessionRepository.hasPendingEnrollment(), ) - val callout = remember(customIntent) { - customIntent?.takeIf(SimprintsIntentUtils::isCallout) - ?.let(SimprintsIntentUtils::prepareCallout) - } + val callout = + remember(customIntent) { + customIntent + ?.takeIf(SimprintsIntentUtils::isCallout) + ?.let(SimprintsIntentUtils::prepareCallout) + } val placeholderValue = resources.getString(R.string.from_last_biometric_search) return remember( From 7f9d3e271f9f9364f97803652815f76ea66dcd10 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Apr 2026 13:23:34 +0100 Subject: [PATCH 04/17] Simprints Confirm Identity & Enrol Last: tests --- .../SimprintsCustomIntentResultMapperTest.kt | 59 ++++ .../SimprintsEnrollmentViewModelTest.kt | 302 ++++++++++++++++++ .../simprints/SimprintsSearchViewModelTest.kt | 265 +++++++++++++++ .../enrollment/EnrollmentPresenterImplTest.kt | 46 +++ .../SearchTEIViewModelTest.kt | 74 +++++ .../repository/SimprintsD2RepositoryTest.kt | 233 ++++++++++++++ .../SimprintsSessionRepositoryTest.kt | 100 ++++++ ...esolveConfirmIdentityCalloutUseCaseTest.kt | 140 ++++++++ ...solvePendingEnrollmentActionUseCaseTest.kt | 196 ++++++++++++ .../utils/SimprintsIntentUtilsTest.kt | 108 +++++++ .../utils/SimprintsSearchUtilsTest.kt | 115 +++++++ .../dhis2/form/data/FormRepositoryImplTest.kt | 76 +++++ .../SimprintsCustomIntentFormPresenterTest.kt | 168 ++++++++++ 13 files changed, 1882 insertions(+) create mode 100644 app/src/test/java/org/dhis2/simprints/SimprintsCustomIntentResultMapperTest.kt create mode 100644 app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt create mode 100644 app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsSessionRepositoryTest.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsSearchUtilsTest.kt create mode 100644 form/src/test/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenterTest.kt diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsCustomIntentResultMapperTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsCustomIntentResultMapperTest.kt new file mode 100644 index 00000000000..2df2e98c35a --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsCustomIntentResultMapperTest.kt @@ -0,0 +1,59 @@ +package org.dhis2.simprints + +import android.content.Intent +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType +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.whenever + +class SimprintsCustomIntentResultMapperTest { + private val contract: CustomIntentActivityResultContract = mock() + private val mapper = SimprintsCustomIntentResultMapper(contract) + + @Test + fun `map should join returned values with commas`() { + val responseData = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ) + val data: Intent = mock() + whenever(contract.mapIntentResponseData(responseData, data)) doReturn + listOf( + "guid-1", + "guid-2", + ) + + val result = mapper.map(responseData, data) + + assertEquals("guid-1,guid-2", result) + } + + @Test + fun `map should return null when contract returns null`() { + val data: Intent = mock() + whenever(contract.mapIntentResponseData(null, data)) doReturn null + + val result = mapper.map(responseData = null, data = data) + + assertNull(result) + } + + @Test + fun `map should return null when contract returns empty list`() { + val data: Intent = mock() + whenever(contract.mapIntentResponseData(null, data)) doReturn emptyList() + + val result = mapper.map(responseData = null, data = data) + + assertNull(result) + } +} diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt new file mode 100644 index 00000000000..c8250f817bd --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt @@ -0,0 +1,302 @@ +package org.dhis2.simprints + +import android.app.Activity.RESULT_OK +import android.content.Intent +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +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.whenever + +class SimprintsEnrollmentViewModelTest { + private val simprintsD2Repository: SimprintsD2Repository = mock() + private val resolvePendingEnrollmentAction: SimprintsResolvePendingEnrollmentActionUseCase = + mock() + private val sessionRepository: SimprintsSessionRepository = mock() + private val resultMapper: SimprintsCustomIntentResultMapper = mock() + + @Test + fun `onFinishRequested should return null when enrollment is not new`() = + runTest { + val viewModel = + SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + val launchIntent = + viewModel.onFinishRequested( + isNewEnrollment = false, + enrollmentUid = "enrollment-uid", + ) + + assertNull(launchIntent) + verify(resolvePendingEnrollmentAction, never()).invoke(any(), any()) + } + + @Test + fun `onFinishRequested should return null when there is no pending enrollment session`() = + runTest { + whenever(sessionRepository.pendingEnrollmentSessionId()) doReturn null + val viewModel = + SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + val launchIntent = + viewModel.onFinishRequested( + isNewEnrollment = true, + enrollmentUid = "enrollment-uid", + ) + + assertNull(launchIntent) + verify(resolvePendingEnrollmentAction, never()).invoke(any(), any()) + } + + @Test + fun `onRegisterLastResult 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.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, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + 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 `onRegisterLastResult should return error when mapped value is missing`() = + 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 null + val viewModel = + SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + viewModel.onFinishRequested( + isNewEnrollment = true, + enrollmentUid = "enrollment-uid", + ) + val result = + viewModel.onRegisterLastResult( + resultCode = RESULT_OK, + data = resultIntent, + teiUid = "tei-uid", + ) + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.ERROR, result) + verify(simprintsD2Repository, never()).saveTrackedEntityAttributeValue( + any(), + any(), + any(), + ) + verify(sessionRepository, never()).clear() + } + + @Test + fun `onRegisterLastResult should return error when tei uid is missing`() = + 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, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + viewModel.onFinishRequested( + isNewEnrollment = true, + enrollmentUid = "enrollment-uid", + ) + val result = + viewModel.onRegisterLastResult( + resultCode = RESULT_OK, + data = mock(), + teiUid = null, + ) + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.ERROR, result) + verify(resultMapper, never()).map(any(), any()) + } + + @Test + fun `onRegisterLastResult should return none when there is no pending action`() = + runTest { + val viewModel = + SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + val result = + viewModel.onRegisterLastResult( + resultCode = RESULT_OK, + data = mock(), + teiUid = "tei-uid", + ) + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.NONE, result) + } + + @Test + fun `onRegisterLastLaunchFailed should discard pending action`() = + 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, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + viewModel.onFinishRequested( + isNewEnrollment = true, + enrollmentUid = "enrollment-uid", + ) + viewModel.onRegisterLastLaunchFailed() + val result = + viewModel.onRegisterLastResult( + resultCode = RESULT_OK, + data = mock(), + teiUid = "tei-uid", + ) + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.NONE, result) + } +} diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt new file mode 100644 index 00000000000..0a746c97a11 --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt @@ -0,0 +1,265 @@ +package org.dhis2.simprints + +import android.app.Activity.RESULT_OK +import android.content.Intent +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.mobile.commons.model.CustomIntentModel +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType +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.Test +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.whenever + +class SimprintsSearchViewModelTest { + private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase = + mock() + private val sessionRepository: SimprintsSessionRepository = mock() + + @Test + fun `onDashboardRequested should launch confirm identity once and clear pending session`() = + runTest { + val launchIntent: Intent = mock() + whenever(sessionRepository.get()) doReturn "session-id" + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any())) doReturn + SimprintsIntentUtils.PreparedCallout( + launchIntent = launchIntent, + responseData = emptyList(), + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) + + val action = + viewModel.onDashboardRequested( + searchFields = listOf(identifyField(value = "guid-1")), + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + ) + + assertTrue(action is SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity) + assertSame( + launchIntent, + (action as SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity).intent, + ) + verify(sessionRepository).clear() + + val navigation = viewModel.onConfirmIdentityResult(RESULT_OK) + assertEquals("tei-uid", navigation?.teiUid) + assertEquals("program-uid", navigation?.programUid) + assertEquals("enrollment-uid", navigation?.enrollmentUid) + assertNull(viewModel.onConfirmIdentityResult(RESULT_OK)) + } + + @Test + fun `onDashboardRequested should open dashboard directly after confirm identity already cleared session`() = + runTest { + whenever(sessionRepository.get()).thenReturn("session-id").thenReturn(null) + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any())) doReturn + SimprintsIntentUtils.PreparedCallout( + launchIntent = mock(), + responseData = emptyList(), + ) + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) + + val firstAction = + viewModel.onDashboardRequested( + searchFields = listOf(identifyField(value = "guid-1")), + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + ) + val secondAction = + viewModel.onDashboardRequested( + searchFields = listOf(identifyField(value = "guid-1")), + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + ) + + assertTrue(firstAction is SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity) + assertTrue(secondAction is SimprintsSearchViewModel.DashboardAction.OpenDashboard) + verify(sessionRepository).clear() + } + + @Test + 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 + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) + + val action = + viewModel.onDashboardRequested( + searchFields = listOf(identifyField(value = "guid-1")), + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + ) + + assertTrue(action is SimprintsSearchViewModel.DashboardAction.OpenDashboard) + verify(sessionRepository, never()).clear() + } + + @Test + fun `prepareEnrollmentQueryData should mark pending enrollment and strip biometric fields`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) + + val filteredQueryData = + viewModel.prepareEnrollmentQueryData( + searchFields = + listOf( + identifyField(value = "guid-1"), + textField(uid = "name", value = "Alice"), + ), + queryData = + mapOf( + "biometric" to listOf("guid-1"), + "name" to listOf("Alice"), + ), + ) + + assertEquals(hashMapOf("name" to listOf("Alice")), filteredQueryData) + verify(sessionRepository).markPendingEnrollment() + } + + @Test + fun `prepareEnrollmentQueryData should keep non biometric searches unchanged`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) + + val filteredQueryData = + viewModel.prepareEnrollmentQueryData( + searchFields = listOf(textField(uid = "name", value = "Alice")), + queryData = mapOf("name" to listOf("Alice")), + ) + + assertEquals(hashMapOf("name" to listOf("Alice")), filteredQueryData) + verify(sessionRepository, never()).markPendingEnrollment() + } + + @Test + fun `shouldUseLastBiometricsLabel should clear pending session when query is no longer biometric`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) + + val shouldUseLastBiometricsLabel = + viewModel.shouldUseLastBiometricsLabel( + searchFields = listOf(textField(uid = "name", value = "Alice")), + ) + + assertFalse(shouldUseLastBiometricsLabel) + verify(sessionRepository).clear() + } + + @Test + fun `shouldUseLastBiometricsLabel should return true only for pending biometric search`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) + + val shouldUseLastBiometricsLabel = + viewModel.shouldUseLastBiometricsLabel( + searchFields = listOf(identifyField(value = "guid-1")), + ) + + assertTrue(shouldUseLastBiometricsLabel) + } + + @Test + fun `onConfirmIdentityResult should clear pending navigation when cancelled`() = + 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, + ) + + viewModel.onDashboardRequested( + searchFields = listOf(identifyField(value = "guid-1")), + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + ) + + assertNull(viewModel.onConfirmIdentityResult(resultCode = 0)) + assertNull(viewModel.onConfirmIdentityResult(RESULT_OK)) + } + + private fun identifyField(value: String?) = + SimprintsSearchUtils.SearchField( + uid = "biometric", + value = value, + customIntent = identifyIntent(), + ) + + private fun textField( + uid: String, + value: String?, + ) = SimprintsSearchUtils.SearchField( + uid = uid, + value = value, + customIntent = null, + ) + + private fun identifyIntent() = + CustomIntentModel( + uid = "identify-intent", + name = "Identify", + packageName = "com.simprints.id.IDENTIFY", + customIntentRequest = emptyList(), + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ), + ) +} diff --git a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt index 709a3049611..805db9035fa 100644 --- a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt @@ -1,7 +1,9 @@ package org.dhis2.usescases.enrollment +import android.content.Intent import io.reactivex.Single import io.reactivex.processors.PublishProcessor +import kotlinx.coroutines.test.runTest import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider @@ -16,6 +18,7 @@ import org.hisp.dhis.android.core.common.Access import org.hisp.dhis.android.core.common.DataAccess import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentAccess import org.hisp.dhis.android.core.enrollment.EnrollmentObjectRepository import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -256,6 +259,49 @@ class EnrollmentPresenterImplTest { assert(!presenter.isEventScheduleOrSkipped("uid")) } + @Test + fun `Should delegate finish request to Simprints enrollment view model`() = runTest { + val intent: Intent = mock() + whenever( + simprintsEnrollmentViewModel.onFinishRequested( + isNewEnrollment = true, + enrollmentUid = "enrollmentUid", + ), + ) doReturn intent + + val result = presenter.onFinishRequested(true, "enrollmentUid") + + assert(result == intent) + } + + @Test + fun `Should delegate register last result using current enrollment tei to Simprints enrollment view model`() = + runTest { + val enrollment: Enrollment = + mock { + on { trackedEntityInstance() } doReturn "teiUid" + } + whenever(enrollmentRepository.blockingGet()) doReturn enrollment + whenever( + simprintsEnrollmentViewModel.onRegisterLastResult( + resultCode = 1, + data = null, + teiUid = "teiUid", + ), + ) doReturn SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH + + val result = presenter.onRegisterLastResult(resultCode = 1, data = null) + + assert(result == SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH) + } + + @Test + fun `Should notify Simprints enrollment view model when register last launch fails`() { + presenter.onRegisterLastLaunchFailed() + + verify(simprintsEnrollmentViewModel).onRegisterLastLaunchFailed() + } + @Test fun `should create an event right after enrollment creation`() { whenever(enrollmentFormRepository.generateEvents()) doReturn 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 89b99b25895..f6310177d2a 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -1,5 +1,7 @@ package org.dhis2.usescases.searchTrackEntity +import android.app.Activity.RESULT_OK +import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List @@ -38,6 +40,7 @@ import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityType import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -424,6 +427,77 @@ class SearchTEIViewModelTest { verify(repository).filterQueryForProgram(viewModel.queryData, "programUid") } + @Test + fun `Should emit launch Simprints confirm identity navigation when opening dashboard requires callout`() = + runTest { + val intent: Intent = mock() + whenever( + simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any()), + ) doReturn SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity(intent) + + viewModel.simprintsNavigation.test { + viewModel.onOpenDashboardRequested("teiUid", "programUid", "enrollmentUid") + testingDispatcher.scheduler.advanceUntilIdle() + + val action = awaitItem() + assertTrue(action is SimprintsNavigationAction.LaunchConfirmIdentity) + assertEquals(intent, (action as SimprintsNavigationAction.LaunchConfirmIdentity).intent) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Should emit open dashboard navigation after Simprints confirm identity succeeds`() = + runTest { + whenever(simprintsSearchViewModel.onConfirmIdentityResult(RESULT_OK)) doReturn + SimprintsSearchViewModel.PendingDashboardNavigation( + teiUid = "teiUid", + programUid = "programUid", + enrollmentUid = "enrollmentUid", + ) + + viewModel.simprintsNavigation.test { + viewModel.onConfirmIdentityResult(RESULT_OK) + 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 error message when Simprints confirm identity setup fails`() = + runTest { + whenever( + simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any()), + ).thenThrow(RuntimeException()) + whenever(resourceManager.getString(R.string.custom_intent_error)) doReturn "Custom intent error" + + viewModel.simprintsNavigation.test { + viewModel.onOpenDashboardRequested("teiUid", "programUid", "enrollmentUid") + testingDispatcher.scheduler.advanceUntilIdle() + + val action = awaitItem() + assertTrue(action is SimprintsNavigationAction.ShowMessage) + assertEquals("Custom intent error", (action as SimprintsNavigationAction.ShowMessage).message) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Should refresh Simprints last biometrics label state`() { + whenever(simprintsSearchViewModel.shouldUseLastBiometricsLabel(any())) doReturn true + + viewModel.refreshSimprintsUiState() + + assertTrue(viewModel.isSimprintsUseLastBiometricsLabel.value == true) + } + @Test fun `Should enroll on click`() { viewModel.onEnrollClick() diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt new file mode 100644 index 00000000000..640383229ae --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt @@ -0,0 +1,233 @@ +package org.dhis2.commons.simprints.repository + +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector +import org.hisp.dhis.android.core.common.ObjectWithUid +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttribute +import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttributeCollectionRepository +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValueObjectRepository +import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance +import org.hisp.dhis.android.core.trackedentity.TrackedEntityTypeAttribute +import org.hisp.dhis.android.core.trackedentity.TrackedEntityTypeAttributeCollectionRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SimprintsD2RepositoryTest { + private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) + private val repository = SimprintsD2Repository(d2) + + @Test + fun `getEnrollmentContext should map enrollment fields`() { + whenever( + d2 + .enrollmentModule() + .enrollments() + .uid("enrollment-uid") + .blockingGet(), + ) doReturn + Enrollment + .builder() + .uid("enrollment-uid") + .trackedEntityInstance("tei-uid") + .program("program-uid") + .organisationUnit("org-unit-uid") + .build() + + val result = repository.getEnrollmentContext("enrollment-uid") + + assertEquals( + SimprintsD2Repository.EnrollmentContext( + teiUid = "tei-uid", + programUid = "program-uid", + orgUnitUid = "org-unit-uid", + ), + result, + ) + } + + @Test + fun `getEnrollmentContext should return null when enrollment is missing`() { + whenever( + d2 + .enrollmentModule() + .enrollments() + .uid("enrollment-uid") + .blockingGet(), + ) doReturn null + + val result = repository.getEnrollmentContext("enrollment-uid") + + assertNull(result) + } + + @Test + fun `getEnrollmentContext should return null when tracked entity instance is missing`() { + whenever( + d2 + .enrollmentModule() + .enrollments() + .uid("enrollment-uid") + .blockingGet(), + ) doReturn + Enrollment + .builder() + .uid("enrollment-uid") + .program("program-uid") + .organisationUnit("org-unit-uid") + .build() + + val result = repository.getEnrollmentContext("enrollment-uid") + + assertNull(result) + } + + @Test + fun `getProgramAttributeUids should map non null tracked entity attributes`() { + val programFilter: StringFilterConnector = + mock() + val programAttributeRepository: ProgramTrackedEntityAttributeCollectionRepository = mock() + whenever( + d2.programModule().programTrackedEntityAttributes().byProgram(), + ) doReturn programFilter + whenever(programFilter.eq("program-uid")) doReturn programAttributeRepository + whenever(programAttributeRepository.blockingGet()) doReturn + listOf( + ProgramTrackedEntityAttribute + .builder() + .uid("program-attribute-1") + .trackedEntityAttribute(ObjectWithUid.create("attribute-1")) + .build(), + ProgramTrackedEntityAttribute + .builder() + .uid("program-attribute-2") + .build(), + ProgramTrackedEntityAttribute + .builder() + .uid("program-attribute-3") + .trackedEntityAttribute(ObjectWithUid.create("attribute-3")) + .build(), + ) + + val result = repository.getProgramAttributeUids("program-uid") + + assertEquals(listOf("attribute-1", "attribute-3"), result) + } + + @Test + fun `getTrackedEntityTypeAttributeUids should map type attribute uids`() { + whenever( + d2 + .trackedEntityModule() + .trackedEntityInstances() + .uid("tei-uid") + .blockingGet(), + ) doReturn + TrackedEntityInstance + .builder() + .uid("tei-uid") + .trackedEntityType("tracked-entity-type-uid") + .build() + val typeFilter: StringFilterConnector = + mock() + val typeAttributeRepository: TrackedEntityTypeAttributeCollectionRepository = mock() + whenever( + d2.trackedEntityModule().trackedEntityTypeAttributes().byTrackedEntityTypeUid(), + ) doReturn typeFilter + whenever(typeFilter.eq("tracked-entity-type-uid")) doReturn typeAttributeRepository + whenever(typeAttributeRepository.blockingGet()) doReturn + listOf( + TrackedEntityTypeAttribute + .builder() + .trackedEntityType(ObjectWithUid.create("tracked-entity-type-uid")) + .trackedEntityAttribute(ObjectWithUid.create("attribute-1")) + .displayInList(true) + .searchable(true) + .build(), + TrackedEntityTypeAttribute + .builder() + .trackedEntityType(ObjectWithUid.create("tracked-entity-type-uid")) + .displayInList(true) + .searchable(true) + .build(), + TrackedEntityTypeAttribute + .builder() + .trackedEntityType(ObjectWithUid.create("tracked-entity-type-uid")) + .trackedEntityAttribute(ObjectWithUid.create("attribute-3")) + .displayInList(true) + .searchable(true) + .build(), + ) + + val result = repository.getTrackedEntityTypeAttributeUids("tei-uid") + + assertEquals(listOf("attribute-1", "attribute-3"), result) + } + + @Test + fun `getTrackedEntityTypeAttributeUids should return empty list when tracked entity instance is missing`() { + whenever( + d2 + .trackedEntityModule() + .trackedEntityInstances() + .uid("tei-uid") + .blockingGet(), + ) doReturn null + + val result = repository.getTrackedEntityTypeAttributeUids("tei-uid") + + assertTrue(result.isEmpty()) + } + + @Test + fun `getTrackedEntityAttributeValue should return stored attribute value`() { + whenever( + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .value("attribute-uid", "tei-uid") + .blockingGet(), + ) doReturn + TrackedEntityAttributeValue + .builder() + .trackedEntityInstance("tei-uid") + .trackedEntityAttribute("attribute-uid") + .value("subject-guid") + .build() + + val result = + repository.getTrackedEntityAttributeValue( + teiUid = "tei-uid", + attributeUid = "attribute-uid", + ) + + assertEquals("subject-guid", result) + } + + @Test + fun `saveTrackedEntityAttributeValue should write attribute value`() { + val attributeValueRepository: TrackedEntityAttributeValueObjectRepository = mock() + whenever( + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .value("attribute-uid", "tei-uid"), + ) doReturn attributeValueRepository + + repository.saveTrackedEntityAttributeValue( + teiUid = "tei-uid", + attributeUid = "attribute-uid", + value = "subject-guid", + ) + + verify(attributeValueRepository).blockingSet("subject-guid") + } +} 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 new file mode 100644 index 00000000000..67e35a34ab9 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsSessionRepositoryTest.kt @@ -0,0 +1,100 @@ +package org.dhis2.commons.simprints.repository + +import org.dhis2.commons.prefs.PreferenceProvider +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.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SimprintsSessionRepositoryTest { + private val preferenceProvider: PreferenceProvider = mock() + private val repository = SimprintsSessionRepository(preferenceProvider) + + @Test + fun `save should store session id and clear pending enrollment flag`() { + repository.save("session-id") + + verify(preferenceProvider).setValue( + SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID, + "session-id", + ) + verify(preferenceProvider).removeValue(SimprintsSessionRepository.PENDING_ENROLL_LAST) + } + + @Test + fun `markPendingEnrollment should persist flag only when session exists`() { + whenever( + preferenceProvider.getString( + SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID, + null, + ), + ) doReturn "session-id" + + repository.markPendingEnrollment() + + verify(preferenceProvider).setValue(SimprintsSessionRepository.PENDING_ENROLL_LAST, true) + } + + @Test + fun `markPendingEnrollment should not persist flag when session is missing`() { + whenever( + preferenceProvider.getString( + SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID, + null, + ), + ) doReturn null + + repository.markPendingEnrollment() + + verify(preferenceProvider, never()).setValue( + SimprintsSessionRepository.PENDING_ENROLL_LAST, + true, + ) + } + + @Test + fun `pendingEnrollmentSessionId should return saved session only when pending flag is true`() { + whenever( + preferenceProvider.getString( + SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID, + null, + ), + ) doReturn "session-id" + whenever( + preferenceProvider.getBoolean(SimprintsSessionRepository.PENDING_ENROLL_LAST, false), + ) doReturn true + + val sessionId = repository.pendingEnrollmentSessionId() + + assertEquals("session-id", sessionId) + } + + @Test + fun `pendingEnrollmentSessionId should return null when pending flag is false`() { + whenever( + preferenceProvider.getString( + SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID, + null, + ), + ) doReturn "session-id" + whenever( + preferenceProvider.getBoolean(SimprintsSessionRepository.PENDING_ENROLL_LAST, false), + ) doReturn false + + val sessionId = repository.pendingEnrollmentSessionId() + + assertNull(sessionId) + } + + @Test + fun `clear should remove both session id and pending enrollment flag`() { + repository.clear() + + verify(preferenceProvider).removeValue(SimprintsSessionRepository.LAST_IDENTIFICATION_SESSION_ID) + verify(preferenceProvider).removeValue(SimprintsSessionRepository.PENDING_ENROLL_LAST) + } +} 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 new file mode 100644 index 00000000000..43727a6ba26 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt @@ -0,0 +1,140 @@ +package org.dhis2.commons.simprints.usecases + +import android.content.Intent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +import org.dhis2.mobile.commons.model.CustomIntentModel +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.assertSame +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SimprintsResolveConfirmIdentityCalloutUseCaseTest { + private val repository: SimprintsD2Repository = mock() + + @Test + fun `invoke should return confirm identity callout when search and selected guid are available`() = + runBlocking { + val customIntent = identifyIntent() + whenever( + repository.getTrackedEntityAttributeValue( + "tei-uid", + "biometric", + ), + ) doReturn "selected-guid" + val useCase = + SimprintsResolveConfirmIdentityCalloutUseCase( + simprintsD2Repository = repository, + ioDispatcher = Dispatchers.Unconfined, + ) + + 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 = "guid-1", + customIntent = customIntent, + ), + ), + sessionId = "session-id", + ) + + 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 search value is blank`() = + runBlocking { + val useCase = + SimprintsResolveConfirmIdentityCalloutUseCase( + simprintsD2Repository = repository, + ) + + val result = + useCase( + teiUid = "tei-uid", + searchFields = + listOf( + SimprintsSearchUtils.SearchField( + uid = "biometric", + value = null, + customIntent = identifyIntent(), + ), + ), + sessionId = "session-id", + ) + + assertNull(result) + } + + @Test + fun `invoke should return null when selected guid is missing`() = + runBlocking { + whenever( + repository.getTrackedEntityAttributeValue( + "tei-uid", + "biometric", + ), + ) doReturn null + val useCase = + SimprintsResolveConfirmIdentityCalloutUseCase( + simprintsD2Repository = repository, + ) + + val result = + useCase( + teiUid = "tei-uid", + searchFields = + listOf( + SimprintsSearchUtils.SearchField( + uid = "biometric", + value = "guid-1", + customIntent = identifyIntent(), + ), + ), + sessionId = "session-id", + ) + + assertNull(result) + } + + private fun identifyIntent() = + CustomIntentModel( + uid = "identify", + name = "Identify", + packageName = "com.simprints.id.IDENTIFY", + customIntentRequest = emptyList(), + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ), + ) +} diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt new file mode 100644 index 00000000000..4be9d3bcd65 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt @@ -0,0 +1,196 @@ +package org.dhis2.commons.simprints.usecases + +import android.content.Intent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.mobile.commons.customintents.CustomIntentRepository +import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel +import org.dhis2.mobile.commons.model.CustomIntentModel +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.assertSame +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SimprintsResolvePendingEnrollmentActionUseCaseTest { + private val repository: SimprintsD2Repository = mock() + private val customIntentRepository: CustomIntentRepository = mock() + + @Test + fun `invoke should return pending action when register last attribute has no value`() = + runBlocking { + val customIntent = registerLastIntent() + whenever(repository.getEnrollmentContext("enrollment-uid")) doReturn + SimprintsD2Repository.EnrollmentContext( + teiUid = "tei-uid", + programUid = "program-uid", + orgUnitUid = "org-unit-uid", + ) + whenever(repository.getProgramAttributeUids("program-uid")) doReturn listOf("program-attribute") + whenever(repository.getTrackedEntityTypeAttributeUids("tei-uid")) doReturn emptyList() + whenever( + customIntentRepository.getCustomIntent( + "program-attribute", + "org-unit-uid", + CustomIntentActionTypeModel.DATA_ENTRY, + ), + ) doReturn customIntent + whenever( + repository.getTrackedEntityAttributeValue( + "tei-uid", + "program-attribute", + ), + ) doReturn null + + val useCase = + SimprintsResolvePendingEnrollmentActionUseCase( + simprintsD2Repository = repository, + customIntentRepository = customIntentRepository, + ioDispatcher = Dispatchers.Unconfined, + ) + + val intentActions = mutableListOf() + Mockito.mockConstruction(Intent::class.java) { _, context -> + intentActions.add(context.arguments().firstOrNull() as? String) + }.use { construction -> + val result = + useCase( + enrollmentUid = "enrollment-uid", + sessionId = "session-id", + ) + + assertNotNull(result) + val launchIntent = construction.constructed().single() + assertEquals("program-attribute", result!!.fieldUid) + assertEquals(listOf("com.simprints.id.REGISTER_LAST_BIOMETRICS"), intentActions) + assertSame(launchIntent, result.callout.launchIntent) + verify(launchIntent).putExtra("sessionId", "session-id") + assertEquals(customIntent.customIntentResponse, result.callout.responseData) + } + } + + @Test + fun `invoke should return null when only identify callout is available`() = + runBlocking { + whenever(repository.getEnrollmentContext("enrollment-uid")) doReturn + SimprintsD2Repository.EnrollmentContext( + teiUid = "tei-uid", + programUid = "program-uid", + orgUnitUid = "org-unit-uid", + ) + whenever(repository.getProgramAttributeUids("program-uid")) doReturn listOf("program-attribute") + whenever(repository.getTrackedEntityTypeAttributeUids("tei-uid")) doReturn emptyList() + whenever( + customIntentRepository.getCustomIntent( + "program-attribute", + "org-unit-uid", + CustomIntentActionTypeModel.DATA_ENTRY, + ), + ) doReturn identifyIntent() + whenever( + repository.getTrackedEntityAttributeValue( + "tei-uid", + "program-attribute", + ), + ) doReturn null + + val useCase = + SimprintsResolvePendingEnrollmentActionUseCase( + simprintsD2Repository = repository, + customIntentRepository = customIntentRepository, + ) + val result = + useCase( + enrollmentUid = "enrollment-uid", + sessionId = "session-id", + ) + + assertNull(result) + } + + @Test + fun `invoke should return null when attribute already has a value`() = + runBlocking { + whenever(repository.getEnrollmentContext("enrollment-uid")) doReturn + SimprintsD2Repository.EnrollmentContext( + teiUid = "tei-uid", + programUid = "program-uid", + orgUnitUid = "org-unit-uid", + ) + whenever(repository.getProgramAttributeUids("program-uid")) doReturn listOf("program-attribute") + whenever(repository.getTrackedEntityTypeAttributeUids("tei-uid")) doReturn listOf("tei-attribute") + whenever( + customIntentRepository.getCustomIntent( + "program-attribute", + "org-unit-uid", + CustomIntentActionTypeModel.DATA_ENTRY, + ), + ) doReturn registerLastIntent() + whenever( + repository.getTrackedEntityAttributeValue( + "tei-uid", + "program-attribute", + ), + ) doReturn "existing-guid" + + val useCase = + SimprintsResolvePendingEnrollmentActionUseCase( + simprintsD2Repository = repository, + customIntentRepository = customIntentRepository, + ) + val result = + useCase( + enrollmentUid = "enrollment-uid", + sessionId = "session-id", + ) + + assertNull(result) + } + + @Test + fun `invoke should return null when enrollment context is missing`() = + runBlocking { + whenever(repository.getEnrollmentContext("enrollment-uid")) doReturn null + val useCase = + SimprintsResolvePendingEnrollmentActionUseCase( + simprintsD2Repository = repository, + customIntentRepository = customIntentRepository, + ) + + val result = + useCase( + enrollmentUid = "enrollment-uid", + sessionId = "session-id", + ) + + assertNull(result) + } + + private fun identifyIntent() = customIntent(packageName = "com.simprints.id.IDENTIFY") + + private fun registerLastIntent() = customIntent(packageName = "com.simprints.id.VERIFY") + + private fun customIntent(packageName: String) = + CustomIntentModel( + uid = packageName, + name = packageName, + packageName = packageName, + customIntentRequest = emptyList(), + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ), + ) +} diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt new file mode 100644 index 00000000000..880f557382d --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt @@ -0,0 +1,108 @@ +package org.dhis2.commons.simprints.utils + +import android.os.Bundle +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType +import org.junit.Assert.assertEquals +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 SimprintsIntentUtilsTest { + @Test + fun `supportsRegisterLast should accept Simprints callouts except identify`() { + assertTrue(SimprintsIntentUtils.supportsRegisterLast(registerLastIntent())) + assertFalse(SimprintsIntentUtils.supportsRegisterLast(identifyIntent())) + assertFalse(SimprintsIntentUtils.supportsRegisterLast(nonSimprintsIntent())) + } + + @Test + fun `hasPendingValue should require empty value register last support and pending enrollment`() { + assertTrue( + SimprintsIntentUtils.hasPendingValue( + customIntent = registerLastIntent(), + value = null, + hasPendingEnrollment = true, + ), + ) + assertFalse( + SimprintsIntentUtils.hasPendingValue( + customIntent = registerLastIntent(), + value = "guid-1", + hasPendingEnrollment = true, + ), + ) + assertFalse( + SimprintsIntentUtils.hasPendingValue( + customIntent = identifyIntent(), + value = null, + hasPendingEnrollment = true, + ), + ) + } + + @Test + fun `getDisplayValues should prefer stored value then pending placeholder`() { + assertEquals( + listOf("guid-1", "guid-2"), + SimprintsIntentUtils.getDisplayValues( + value = "guid-1,guid-2", + hasPendingValue = true, + placeholderValue = "From last biometric search", + ), + ) + assertEquals( + listOf("From last biometric search"), + SimprintsIntentUtils.getDisplayValues( + value = null, + hasPendingValue = true, + placeholderValue = "From last biometric search", + ), + ) + assertTrue( + SimprintsIntentUtils + .getDisplayValues( + value = null, + hasPendingValue = false, + placeholderValue = "From last biometric search", + ).isEmpty(), + ) + } + + @Test + fun `extractSessionId should read session id extra`() { + val extras = + mock { + on { getString("sessionId") } doReturn "session-id" + } + + val sessionId = SimprintsIntentUtils.extractSessionId(extras) + + assertEquals("session-id", sessionId) + } + + private fun identifyIntent() = customIntent(packageName = "com.simprints.id.IDENTIFY") + + private fun registerLastIntent() = customIntent(packageName = "com.simprints.id.VERIFY") + + private fun nonSimprintsIntent() = customIntent(packageName = "com.example.other.IDENTIFY") + + private fun customIntent(packageName: String) = + CustomIntentModel( + uid = packageName, + name = packageName, + packageName = packageName, + customIntentRequest = emptyList(), + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ), + ) +} diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsSearchUtilsTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsSearchUtilsTest.kt new file mode 100644 index 00000000000..a01b522ae99 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsSearchUtilsTest.kt @@ -0,0 +1,115 @@ +package org.dhis2.commons.simprints.utils + +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SimprintsSearchUtilsTest { + @Test + fun `searchState should detect biometric identification query`() { + val searchState = + SimprintsSearchUtils.searchState( + listOf( + SimprintsSearchUtils.SearchField( + uid = "biometric", + value = "guid-1", + customIntent = identifyIntent(), + ), + ), + ) + + assertTrue(searchState.hasAnyQuery) + assertTrue(searchState.hasBiometricIdentificationQuery) + assertFalse(searchState.shouldClearPendingSession) + } + + @Test + fun `searchState should clear pending session for non biometric query`() { + val searchState = + SimprintsSearchUtils.searchState( + listOf( + SimprintsSearchUtils.SearchField( + uid = "name", + value = "Alice", + customIntent = null, + ), + ), + ) + + assertTrue(searchState.hasAnyQuery) + assertFalse(searchState.hasBiometricIdentificationQuery) + assertTrue(searchState.shouldClearPendingSession) + } + + @Test + fun `filterQueryData should remove biometric identification query`() { + val filteredQueryData = + SimprintsSearchUtils.filterQueryData( + queryData = + mapOf( + "biometric" to listOf("guid-1"), + "name" to listOf("Alice"), + "empty" to emptyList(), + ), + fields = + listOf( + SimprintsSearchUtils.SearchField( + uid = "biometric", + value = "guid-1", + customIntent = identifyIntent(), + ), + SimprintsSearchUtils.SearchField( + uid = "name", + value = "Alice", + customIntent = null, + ), + ), + ) + + assertEquals(hashMapOf("name" to listOf("Alice")), filteredQueryData) + } + + @Test + fun `shouldUseLastBiometricsLabel should require pending biometric session`() { + assertTrue( + SimprintsSearchUtils.shouldUseLastBiometricsLabel( + searchState = + SimprintsSearchUtils.SearchState( + hasAnyQuery = true, + hasBiometricIdentificationQuery = true, + ), + hasPendingSession = true, + ), + ) + assertFalse( + SimprintsSearchUtils.shouldUseLastBiometricsLabel( + searchState = + SimprintsSearchUtils.SearchState( + hasAnyQuery = true, + hasBiometricIdentificationQuery = false, + ), + hasPendingSession = true, + ), + ) + } + + private fun identifyIntent() = + CustomIntentModel( + uid = "identify", + name = "Identify", + packageName = "com.simprints.id.IDENTIFY", + customIntentRequest = emptyList(), + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ), + ) +} diff --git a/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt b/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt index ecf197cd003..cf6a7c35c32 100644 --- a/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt +++ b/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt @@ -17,6 +17,9 @@ import org.dhis2.form.model.StoreResult import org.dhis2.form.model.ValueStoreResult import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.form.ui.provider.LegendValueProvider +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hamcrest.MatcherAssert.assertThat @@ -455,6 +458,45 @@ class FormRepositoryImplTest { assertTrue(repository.runDataIntegrityCheck(false) is SuccessfulResult) } + @Test + fun `Should treat pending Simprints register last value as completed and valid`() = + runBlocking { + whenever( + dataEntryRepository.list(), + ) doReturn Flowable.just(providePendingSimprintsMandatoryItemList()) + whenever( + preferenceProvider.getString("SID_LAST_IDENTIFICATION_SESSION_ID", null), + ) doReturn "session-id" + whenever( + preferenceProvider.getBoolean("SID_PENDING_ENROLL_LAST", false), + ) doReturn true + whenever( + dataEntryRepository.updateSection( + any(), + any(), + any(), + any(), + any(), + any(), + ), + ).thenAnswer { invocation -> + (invocation.getArgument(0) as SectionUiModelImpl).copy( + isOpen = invocation.getArgument(1), + totalFields = invocation.getArgument(2), + completedFields = invocation.getArgument(3), + errors = invocation.getArgument(4), + warnings = invocation.getArgument(5), + ) + } + + val result = repository.fetchFormItems() + val section = result.filterIsInstance().first() + + assertEquals(1f, repository.completedFieldsPercentage(result), 0f) + assertEquals(1, section.completedFields) + assertTrue(repository.runDataIntegrityCheck(false) is SuccessfulResult) + } + private fun mockedSections() = listOf( "section1", @@ -613,6 +655,40 @@ class FormRepositoryImplTest { ), ) + private fun providePendingSimprintsMandatoryItemList() = + listOf( + section1(), + FieldUiModelImpl( + uid = "uid001", + value = null, + displayName = null, + label = "BiometricSubjectID", + valueType = ValueType.TEXT, + programStageSection = "section1", + uiEventFactory = null, + mandatory = true, + optionSetConfiguration = null, + autocompleteList = null, + customIntent = registerLastIntent(), + ), + ) + + private fun registerLastIntent() = + CustomIntentModel( + uid = "register-last", + name = "Register last", + packageName = "com.simprints.id.VERIFY", + customIntentRequest = emptyList(), + customIntentResponse = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ), + ) + @Test fun `reEvaluateRequestParams should call dataEntryRepository and map results`() { val customIntentUid = "custom-intent-uid" diff --git a/form/src/test/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenterTest.kt b/form/src/test/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenterTest.kt new file mode 100644 index 00000000000..90a839f8a1a --- /dev/null +++ b/form/src/test/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenterTest.kt @@ -0,0 +1,168 @@ +package org.dhis2.form.simprints + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.os.Bundle +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +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 + +class SimprintsCustomIntentFormPresenterTest { + private val sessionRepository: SimprintsSessionRepository = mock() + private val contract: CustomIntentActivityResultContract = mock() + + @Test + fun `handleResult should return mapped value and save session id when requested`() { + val responseData = responseData() + val extras = + mock { + on { getString("sessionId") } doReturn "session-id" + } + val data = + mock { + on { this.extras } doReturn extras + } + whenever(contract.mapIntentResponseData(responseData, data)) doReturn + listOf( + "guid-1", + "guid-2", + ) + val presenter = + SimprintsCustomIntentFormPresenter( + fieldValue = null, + callout = preparedCallout(responseData), + capturesSessionId = true, + sessionRepository = sessionRepository, + contract = contract, + placeholderValue = "From last biometric search", + hasPendingValue = false, + ) + + val result = presenter.handleResult(RESULT_OK, data) + + assertEquals("guid-1,guid-2", result) + verify(sessionRepository).save("session-id") + } + + @Test + fun `handleResult should return null when activity result is not ok`() { + val presenter = + SimprintsCustomIntentFormPresenter( + fieldValue = null, + callout = preparedCallout(responseData()), + capturesSessionId = true, + sessionRepository = sessionRepository, + contract = contract, + placeholderValue = "From last biometric search", + hasPendingValue = false, + ) + + val result = presenter.handleResult(resultCode = 0, data = mock()) + + assertNull(result) + verify(contract, never()).mapIntentResponseData( + org.mockito.kotlin.anyOrNull(), + org.mockito.kotlin.anyOrNull(), + ) + } + + @Test + fun `displayValues should show placeholder when pending value exists`() { + val presenter = + SimprintsCustomIntentFormPresenter( + fieldValue = null, + callout = null, + capturesSessionId = false, + sessionRepository = sessionRepository, + contract = contract, + placeholderValue = "From last biometric search", + hasPendingValue = true, + ) + + assertEquals(listOf("From last biometric search"), presenter.displayValues()) + } + + @Test + fun `prepareLaunch should clear session when presenter handles launch`() { + val presenter = + SimprintsCustomIntentFormPresenter( + fieldValue = null, + callout = preparedCallout(responseData()), + capturesSessionId = false, + sessionRepository = sessionRepository, + contract = contract, + placeholderValue = "From last biometric search", + hasPendingValue = false, + ) + + presenter.prepareLaunch() + + verify(sessionRepository).clear() + } + + @Test + fun `clearPendingValue should clear session when pending value exists`() { + val presenter = + SimprintsCustomIntentFormPresenter( + fieldValue = null, + callout = null, + capturesSessionId = false, + sessionRepository = sessionRepository, + contract = contract, + placeholderValue = "From last biometric search", + hasPendingValue = true, + ) + + presenter.clearPendingValue() + + verify(sessionRepository).clear() + } + + @Test + fun `createLaunchIntent should expose prepared launch intent`() { + val launchIntent: Intent = mock() + val presenter = + SimprintsCustomIntentFormPresenter( + fieldValue = null, + callout = + SimprintsIntentUtils.PreparedCallout( + launchIntent = launchIntent, + responseData = responseData(), + ), + capturesSessionId = false, + sessionRepository = sessionRepository, + contract = contract, + placeholderValue = "From last biometric search", + hasPendingValue = false, + ) + + assertTrue(presenter.handlesLaunch) + assertEquals(launchIntent, presenter.createLaunchIntent()) + } + + private fun preparedCallout(responseData: List) = + SimprintsIntentUtils.PreparedCallout( + launchIntent = mock(), + responseData = responseData, + ) + + private fun responseData() = + listOf( + CustomIntentResponseDataModel( + name = "guid", + extraType = CustomIntentResponseExtraType.STRING, + key = null, + ), + ) +} From 742640f055cc785e5c10efa63a932afa0fa9415d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Apr 2026 13:43:48 +0100 Subject: [PATCH 05/17] Simprints Confirm Identity & Enrol Last: pending Enrol Last more narrowly linked to the configured Enrol in the TEI editing forms --- ...tsResolvePendingEnrollmentActionUseCase.kt | 2 +- .../simprints/utils/SimprintsIntentUtils.kt | 5 ++-- ...solvePendingEnrollmentActionUseCaseTest.kt | 6 ++--- .../utils/SimprintsIntentUtilsTest.kt | 24 +++++++++---------- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt index 438ef43e947..3ef5be1282a 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt @@ -56,7 +56,7 @@ class SimprintsResolvePendingEnrollmentActionUseCase( orgUnitUid, CustomIntentActionTypeModel.DATA_ENTRY, ) ?: return null - if (!SimprintsIntentUtils.supportsRegisterLast(customIntent)) { + if (!SimprintsIntentUtils.isRegisterCallout(customIntent)) { return null } if (!simprintsD2Repository diff --git a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt index f1042d4ff88..973d500ba48 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt @@ -8,6 +8,7 @@ import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel object SimprintsIntentUtils { private const val SIMPRINTS_PACKAGE_NAME = "com.simprints.id" + private const val SIMPRINTS_REGISTER_ACTION = "$SIMPRINTS_PACKAGE_NAME.REGISTER" private const val SIMPRINTS_IDENTIFY_ACTION = "$SIMPRINTS_PACKAGE_NAME.IDENTIFY" private const val SIMPRINTS_CONFIRM_IDENTITY_ACTION = "$SIMPRINTS_PACKAGE_NAME.CONFIRM_IDENTITY" private const val SIMPRINTS_REGISTER_LAST_ACTION = "$SIMPRINTS_PACKAGE_NAME.REGISTER_LAST_BIOMETRICS" @@ -23,7 +24,7 @@ object SimprintsIntentUtils { fun isIdentifyCallout(customIntent: CustomIntentModel?): Boolean = customIntent?.packageName == SIMPRINTS_IDENTIFY_ACTION - fun supportsRegisterLast(customIntent: CustomIntentModel?): Boolean = isCallout(customIntent) && !isIdentifyCallout(customIntent) + fun isRegisterCallout(customIntent: CustomIntentModel?): Boolean = customIntent?.packageName == SIMPRINTS_REGISTER_ACTION fun extractSessionId(extras: Bundle?): String? = extras?.getString(SIMPRINTS_SESSION_ID_KEY) @@ -63,7 +64,7 @@ object SimprintsIntentUtils { hasPendingEnrollment: Boolean, ): Boolean = value.isNullOrEmpty() && - supportsRegisterLast(customIntent) && + isRegisterCallout(customIntent) && hasPendingEnrollment fun getDisplayValues( diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt index 4be9d3bcd65..916b42e00ed 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt @@ -27,7 +27,7 @@ class SimprintsResolvePendingEnrollmentActionUseCaseTest { @Test fun `invoke should return pending action when register last attribute has no value`() = runBlocking { - val customIntent = registerLastIntent() + val customIntent = registerIntent() whenever(repository.getEnrollmentContext("enrollment-uid")) doReturn SimprintsD2Repository.EnrollmentContext( teiUid = "tei-uid", @@ -133,7 +133,7 @@ class SimprintsResolvePendingEnrollmentActionUseCaseTest { "org-unit-uid", CustomIntentActionTypeModel.DATA_ENTRY, ), - ) doReturn registerLastIntent() + ) doReturn registerIntent() whenever( repository.getTrackedEntityAttributeValue( "tei-uid", @@ -176,7 +176,7 @@ class SimprintsResolvePendingEnrollmentActionUseCaseTest { private fun identifyIntent() = customIntent(packageName = "com.simprints.id.IDENTIFY") - private fun registerLastIntent() = customIntent(packageName = "com.simprints.id.VERIFY") + private fun registerIntent() = customIntent(packageName = "com.simprints.id.REGISTER") private fun customIntent(packageName: String) = CustomIntentModel( diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt index 880f557382d..8dbb65c0a47 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt @@ -13,24 +13,24 @@ import org.mockito.kotlin.mock class SimprintsIntentUtilsTest { @Test - fun `supportsRegisterLast should accept Simprints callouts except identify`() { - assertTrue(SimprintsIntentUtils.supportsRegisterLast(registerLastIntent())) - assertFalse(SimprintsIntentUtils.supportsRegisterLast(identifyIntent())) - assertFalse(SimprintsIntentUtils.supportsRegisterLast(nonSimprintsIntent())) - } - - @Test - fun `hasPendingValue should require empty value register last support and pending enrollment`() { + fun `hasPendingValue should require empty value and register intent and pending enrollment`() { assertTrue( SimprintsIntentUtils.hasPendingValue( - customIntent = registerLastIntent(), + customIntent = registerIntent(), value = null, hasPendingEnrollment = true, ), ) assertFalse( SimprintsIntentUtils.hasPendingValue( - customIntent = registerLastIntent(), + customIntent = registerIntent(), + value = null, + hasPendingEnrollment = false, + ), + ) + assertFalse( + SimprintsIntentUtils.hasPendingValue( + customIntent = registerIntent(), value = "guid-1", hasPendingEnrollment = true, ), @@ -86,9 +86,7 @@ class SimprintsIntentUtilsTest { private fun identifyIntent() = customIntent(packageName = "com.simprints.id.IDENTIFY") - private fun registerLastIntent() = customIntent(packageName = "com.simprints.id.VERIFY") - - private fun nonSimprintsIntent() = customIntent(packageName = "com.example.other.IDENTIFY") + private fun registerIntent() = customIntent(packageName = "com.simprints.id.REGISTER") private fun customIntent(packageName: String) = CustomIntentModel( From 73e8192fd6febd437453b718e28dfe1731ac7611 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Apr 2026 18:18:36 +0100 Subject: [PATCH 06/17] Simprints Identification intent moduleId to be user's Org Unit name --- .../CustomIntentRepositoryImpl.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt index 5e9dc2c2129..28d3d1f1bdd 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt @@ -100,6 +100,26 @@ class CustomIntentRepositoryImpl( .settingModule() .customIntentService() .blockingEvaluateRequestParams(customIntent, context) + .overrideSimprintsIdentifyModuleIdWithUserOrgUnit(customIntent) + + private fun Map.overrideSimprintsIdentifyModuleIdWithUserOrgUnit(customIntent: CustomIntent): Map { + val simprintsIdentifyAction = "com.simprints.id.IDENTIFY" + val simprintsModuleIdKey = "moduleId" + if (customIntent.packageName() != simprintsIdentifyAction) { + return this + } + val orgUnitCode = currentUserOrgUnitName() + ?: return this + return this + (simprintsModuleIdKey to orgUnitCode) + } + + private fun currentUserOrgUnitName(): String? = + d2 + .organisationUnitModule() + .organisationUnits() + .byRootOrganisationUnit(true) + .blockingGet() + .firstNotNullOfOrNull { it.name()?.takeUnless(String::isBlank) } override fun reEvaluateCustomIntentRequestParams( orgUnitUid: String, From 9c3b9a5b00362e90296cd78d6328c3cc642e1b1a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 8 Apr 2026 13:41:49 +0100 Subject: [PATCH 07/17] Simprints Identification search result order preservation by match score --- .../searchTrackEntity/SearchTEIViewModel.kt | 40 ++++ .../searchTrackEntity/SearchTEModule.java | 19 +- .../SearchTeiViewModelFactory.kt | 3 + .../SearchTEIViewModelTest.kt | 138 ++++++++++++++ ...rSearchResultsByIdentifyResponseUseCase.kt | 67 +++++++ ...rchResultsByIdentifyResponseUseCaseTest.kt | 179 ++++++++++++++++++ 6 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest.kt 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 3126a6ade17..9f2803c7f24 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -30,6 +30,7 @@ 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 @@ -43,6 +44,7 @@ import org.dhis2.commons.extensions.toPercentage import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase import org.dhis2.commons.simprints.utils.SimprintsSearchUtils import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel @@ -101,6 +103,7 @@ class SearchTEIViewModel( private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, private val simprintsSearchViewModel: SimprintsSearchViewModel, + private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase, ) : ViewModel() { private var layersVisibility: Map = emptyMap() @@ -484,6 +487,10 @@ class SearchTEIViewModel( selectedProgram = searchRepository.getProgram(initialProgramUid), queryData = queryData, ) + loadSimprintsBiometricSearchResults( + searchParametersModel = searchParametersModel, + isOnline = searching && networkUtils.isOnline(), + )?.let { return@withContext it } val getPagingData = searchRepositoryKt.searchTrackedEntities( searchParametersModel, @@ -558,6 +565,10 @@ class SearchTEIViewModel( ) return@withContext if (searching) { + loadSimprintsBiometricSearchResults( + searchParametersModel = searchParametersModel, + isOnline = networkUtils.isOnline(), + )?.let { return@withContext it } getPagingData.map { pagingData -> pagingData.map { item -> withContext(dispatchers.io()) { @@ -588,6 +599,35 @@ class SearchTEIViewModel( } } + private suspend fun loadSimprintsBiometricSearchResults( + 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)) + } + fun fetchMapResults() { CoroutineTracker.increment() viewModelScope.launch(dispatchers.io()) { 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 394e93699ee..5772617f765 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -24,6 +24,7 @@ import org.dhis2.commons.resources.ResourceManager; import org.dhis2.commons.simprints.repository.SimprintsD2Repository; import org.dhis2.commons.simprints.repository.SimprintsSessionRepository; +import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase; import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase; import org.dhis2.commons.schedulers.SchedulerProvider; import org.dhis2.commons.viewmodel.DispatcherProvider; @@ -348,6 +349,18 @@ SimprintsResolveConfirmIdentityCalloutUseCase provideSimprintsResolveConfirmIden ); } + @Provides + @PerActivity + SimprintsOrderSearchResultsByIdentifyResponseUseCase provideSimprintsOrderSearchResultsByIdentifyResponseUseCase( + SimprintsD2Repository simprintsD2Repository, + DispatcherProvider dispatcherProvider + ) { + return new SimprintsOrderSearchResultsByIdentifyResponseUseCase( + simprintsD2Repository, + dispatcherProvider.io() + ); + } + @Provides @PerActivity SimprintsSearchViewModel provideSimprintsSearchViewModel( @@ -372,7 +385,8 @@ SearchTeiViewModelFactory providesViewModelFactory( DisplayNameProvider displayNameProvider, FilterManager filterManager, ProgramConfigurationRepository programConfigurationRepository, - SimprintsSearchViewModel simprintsSearchViewModel + SimprintsSearchViewModel simprintsSearchViewModel, + SimprintsOrderSearchResultsByIdentifyResponseUseCase orderSearchResultsByIdentifyResponse ) { return new SearchTeiViewModelFactory( searchRepository, @@ -392,7 +406,8 @@ SearchTeiViewModelFactory providesViewModelFactory( resourceManager, displayNameProvider, filterManager, - simprintsSearchViewModel + simprintsSearchViewModel, + orderSearchResultsByIdentifyResponse ); } 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 fe3a6f28997..d87e691f3d3 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt @@ -5,6 +5,7 @@ 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 @@ -24,6 +25,7 @@ class SearchTeiViewModelFactory( private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, private val simprintsSearchViewModel: SimprintsSearchViewModel, + private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = SearchTEIViewModel( @@ -40,5 +42,6 @@ class SearchTeiViewModelFactory( displayNameProvider, filterManager, simprintsSearchViewModel, + orderSearchResultsByIdentifyResponse, ) as T } 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 f6310177d2a..1df3b5ab6d1 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -14,6 +14,8 @@ import app.cash.turbine.test import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take import kotlinx.coroutines.test.StandardTestDispatcher @@ -24,6 +26,7 @@ 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 @@ -32,12 +35,14 @@ import org.dhis2.form.ui.intent.FormIntent 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.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.TrackedEntityType +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem import org.junit.After import org.junit.Assert.assertEquals @@ -47,6 +52,7 @@ import org.junit.Rule import org.junit.Test import org.maplibre.geojson.BoundingBox import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.times @@ -75,6 +81,7 @@ class SearchTEIViewModelTest { private val displayNameProvider: DisplayNameProvider = mock() private val filterManager: FilterManager = mock() private val simprintsSearchViewModel: SimprintsSearchViewModel = mock() + private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase = mock() @ExperimentalCoroutinesApi private val testingDispatcher = StandardTestDispatcher() @@ -109,6 +116,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, + orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, ) testingDispatcher.scheduler.advanceUntilIdle() } @@ -320,6 +328,93 @@ class SearchTEIViewModelTest { assertTrue(result.isEmpty()) } + @Test + fun `Should return ordered Simprints biometric search results when available`() = + runTest { + val testingProgram = testingProgram(displayFrontPageList = false) + 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( + 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 + 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() + testingDispatcher.scheduler.advanceUntilIdle() + + val result = async { viewModel.searchPagingData.drop(1).take(1).asSnapshot() } + viewModel.onSearch() + testingDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf(secondModel, firstModel), result.await()) + verify(repositoryKt, times(0)).searchTrackedEntities(any(), any()) + } + + @Test + fun `Should fall back to regular search when Simprints ordering is not available`() = + runTest { + val testingProgram = testingProgram(displayFrontPageList = false) + 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( + any(), + anyOrNull(), + any List>(), + ), + ) doReturn null + whenever(repositoryKt.searchTrackedEntities(any(), any())) doReturn + flowOf(PagingData.from(listOf(searchItem))) + whenever(repository.transform(searchItem, testingProgram, false, null)) doReturn searchModel + 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() + testingDispatcher.scheduler.advanceUntilIdle() + + val result = async { viewModel.searchPagingData.drop(1).take(1).asSnapshot() } + viewModel.onSearch() + testingDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf(searchModel), result.await()) + verify(repositoryKt).searchTrackedEntities(any(), any()) + } + @ExperimentalCoroutinesApi @Test fun `Should fetch map results`() { @@ -870,6 +965,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, + orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -917,6 +1013,7 @@ class SearchTEIViewModelTest { displayNameProvider = displayNameProvider, filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, + orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -1032,6 +1129,47 @@ class SearchTEIViewModelTest { ), ) + private fun simprintsBiometricSearchField() = + FieldUiModelImpl( + uid = "biometric", + label = "Biometric", + value = null, + autocompleteList = emptyList(), + optionSetConfiguration = null, + valueType = ValueType.TEXT, + customIntent = simprintsIdentifyIntent(), + ) + + private fun simprintsIdentifyIntent() = + CustomIntentModel( + uid = "identify", + name = "Identify", + packageName = "com.simprints.id.IDENTIFY", + customIntentRequest = emptyList(), + customIntentResponse = emptyList(), + ) + + private fun trackedEntitySearchItem(uid: String): TrackedEntitySearchItem = + TrackedEntitySearchItem( + uid = uid, + created = null, + lastUpdated = null, + createdAtClient = null, + lastUpdatedAtClient = null, + organisationUnit = "orgUnit", + geometry = null, + syncState = null, + aggregatedSyncState = null, + deleted = false, + type = TrackedEntityType.builder().uid("teiType").build(), + header = uid, + ) + + private fun searchTeiModel(header: String) = + SearchTeiModel().apply { + setHeader(header) + } + private fun testingProgram( displayFrontPageList: Boolean = true, minAttributesToSearch: Int = 1, diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt new file mode 100644 index 00000000000..616e3386bd3 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt @@ -0,0 +1,67 @@ +package org.dhis2.commons.simprints.usecases + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem + +class SimprintsOrderSearchResultsByIdentifyResponseUseCase( + private val simprintsD2Repository: SimprintsD2Repository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + private data class IdentifyResponseOrder( + val fieldUid: String, + val orderedGuids: List, + ) { + val orderByGuid = orderedGuids.distinct().withIndex().associate { it.value to it.index } + } + + suspend operator fun invoke( + searchFields: Iterable, + queryData: Map?>?, + searchTrackedEntities: suspend () -> List, + ): List? = + withContext(ioDispatcher) { + val order = getIdentifyResponseOrder(searchFields, queryData) ?: return@withContext null + searchTrackedEntities() + .map { trackedEntity -> + trackedEntity to getMatchedGuid(trackedEntity, order) + }.sortedBy { (_, guid) -> + guid?.let(order.orderByGuid::get) ?: Int.MAX_VALUE + }.map { (trackedEntity, _) -> + trackedEntity + } + } + + private fun getIdentifyResponseOrder( + searchFields: Iterable, + queryData: Map?>?, + ): IdentifyResponseOrder? = + searchFields.firstNotNullOfOrNull { field -> + queryData + ?.get(field.uid) + ?.filter { it.isNotBlank() } + ?.takeIf { values -> + values.size > 1 && SimprintsIntentUtils.isIdentifyCallout(field.customIntent) + }?.let { values -> + IdentifyResponseOrder(fieldUid = field.uid, orderedGuids = values) + } + } + + private fun getMatchedGuid( + searchItem: TrackedEntitySearchItem, + order: IdentifyResponseOrder, + ): String? { + searchItem.attributeValues + ?.firstOrNull { attribute -> + attribute.attribute == order.fieldUid && attribute.value in order.orderByGuid + }?.run { return value } + + return simprintsD2Repository + .getTrackedEntityAttributeValue(searchItem.uid(), order.fieldUid) + ?.takeIf { it in order.orderByGuid } + } +} diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest.kt new file mode 100644 index 00000000000..90569abb08c --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest.kt @@ -0,0 +1,179 @@ +package org.dhis2.commons.simprints.usecases + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.trackedentity.TrackedEntityType +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemAttribute +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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.whenever + +class SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest { + private val repository: SimprintsD2Repository = mock() + private val useCase = + SimprintsOrderSearchResultsByIdentifyResponseUseCase( + simprintsD2Repository = repository, + ioDispatcher = Dispatchers.Unconfined, + ) + + @Test + fun `invoke should order results by identify response values`() = + runBlocking { + val first = searchItem(uid = "tei-1", biometricGuid = "guid-1") + val second = searchItem(uid = "tei-2", biometricGuid = "guid-2") + + val result = + useCase( + searchFields = listOf(simprintsIdentifyField()), + queryData = mapOf("biometric" to listOf("guid-2", "guid-1")), + searchTrackedEntities = { listOf(first, second) }, + ) + + assertEquals(listOf(second, first), result) + } + + @Test + fun `invoke should use stored attribute value when search item does not include biometric GUID value`() { + runBlocking { + val first = searchItem(uid = "tei-1") + val second = searchItem(uid = "tei-2") + val unmatched = searchItem(uid = "tei-3") + whenever( + repository.getTrackedEntityAttributeValue( + "tei-1", + "biometric", + ), + ) doReturn "guid-1" + whenever( + repository.getTrackedEntityAttributeValue( + "tei-2", + "biometric", + ), + ) doReturn "guid-2" + whenever( + repository.getTrackedEntityAttributeValue( + "tei-3", + "biometric", + ), + ) doReturn "unknown-guid" + + val result = + useCase( + searchFields = listOf(simprintsIdentifyField()), + queryData = mapOf("biometric" to listOf("guid-2", "guid-1")), + searchTrackedEntities = { listOf(unmatched, first, second) }, + ) + + assertEquals(listOf(second, first, unmatched), result) + verify(repository).getTrackedEntityAttributeValue("tei-1", "biometric") + verify(repository).getTrackedEntityAttributeValue("tei-2", "biometric") + verify(repository).getTrackedEntityAttributeValue("tei-3", "biometric") + } + } + + @Test + fun `invoke should return null without searching when identify response has fewer than two values`() = + runBlocking { + var searchWasCalled = false + + val result = + useCase( + searchFields = listOf(simprintsIdentifyField()), + queryData = mapOf("biometric" to listOf("guid-1")), + searchTrackedEntities = { + searchWasCalled = true + emptyList() + }, + ) + + assertNull(result) + assertFalse(searchWasCalled) + } + + @Test + fun `invoke should return null without searching when field is not an identify callout`() = + runBlocking { + var searchWasCalled = false + + val result = + useCase( + searchFields = listOf(nonSimprintsField()), + queryData = mapOf("biometric" to listOf("guid-2", "guid-1")), + searchTrackedEntities = { + searchWasCalled = true + emptyList() + }, + ) + + assertNull(result) + assertFalse(searchWasCalled) + } + + private fun simprintsIdentifyField() = + SimprintsSearchUtils.SearchField( + uid = "biometric", + value = null, + customIntent = customIntent(packageName = "com.simprints.id.IDENTIFY"), + ) + + private fun nonSimprintsField() = + SimprintsSearchUtils.SearchField( + uid = "biometric", + value = null, + customIntent = customIntent(packageName = "com.example.OTHER"), + ) + + private fun customIntent(packageName: String) = + CustomIntentModel( + uid = packageName, + name = packageName, + packageName = packageName, + customIntentRequest = emptyList(), + customIntentResponse = emptyList(), + ) + + private fun searchItem( + uid: String, + biometricGuid: String? = null, + ): TrackedEntitySearchItem = + TrackedEntitySearchItem( + uid = uid, + created = null, + lastUpdated = null, + createdAtClient = null, + lastUpdatedAtClient = null, + organisationUnit = "orgUnit", + geometry = null, + syncState = null, + aggregatedSyncState = null, + deleted = false, + type = TrackedEntityType.builder().uid("teiType").build(), + header = uid, + attributeValues = + biometricGuid?.let { + listOf( + TrackedEntitySearchItemAttribute( + attribute = "biometric", + displayName = "Biometric", + displayFormName = "Biometric", + value = it, + created = null, + lastUpdated = null, + valueType = ValueType.TEXT, + displayInList = true, + optionSet = null, + ), + ) + } ?: emptyList(), + ) +} From 3c276f0e167c73926276d0cf69e6923de7796457 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 8 Apr 2026 14:43:35 +0100 Subject: [PATCH 08/17] Simprints Identification feature descriptions in README --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7e4e65a6c00..97369b2ffde 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,52 @@ DHIS2 data and tracker capture app for Android to support integration with Simpr ### Changes -Summary of changes comparing to upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-android-capture-app): +Summary of changes comparing to +upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-android-capture-app): -| Feature | File / line of code | Type of change | Description | -|-----------------------------------------|----------------------------------------------------------------------------------------------------------|----------------|-----------------------------------------------------------------------------------------------| -| Infra: fork sync from upstream | [simcapture-sync-fork-upstream-main.yml](.github/workflows/simcapture-sync-fork-upstream-main.yml) | New file | GitHub Action to sync upstream's `main` to this repo's `upstream-main` nightly on Mon-Fri | -| Infra: automatic PR on upstream release | [simcapture-upstream-release-pr.yml](.github/workflows/simcapture-upstream-release-pr.yml) | New file | GitHub Action to open a PR when `upstream-main` has an upstream release newly merged into it | -| 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-L29](README.md#L1-L29) | Code change | This section in README | +| Feature | File / line of code | Type of change | Description | +|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------|--------------------------------------------------------------------------------------------------------------------------------| +| Infra: fork sync from upstream | [simcapture-sync-fork-upstream-main.yml](.github/workflows/simcapture-sync-fork-upstream-main.yml) | New file | GitHub Action to sync upstream's `main` to this repo's `upstream-main` nightly on Mon-Fri | +| Infra: automatic PR on upstream release | [simcapture-upstream-release-pr.yml](.github/workflows/simcapture-upstream-release-pr.yml) | New file | GitHub Action to open a PR when `upstream-main` has an upstream release newly merged into it | +| 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-L35](README.md#L1-L35) | Code change | This section in README | +| Identification+ Enrol Last | [EnrollmentActivity.kt#L61](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L61) | Code change | 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 change | Delegates save-time Enrol Last resolution and result handling | +| Identification+ Enrol Last | [EnrollmentModule.kt#L155](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L155) | Code change | DI for separate Simprints-specific components for Identification+ Enrol Last | +| Identification+ Enrol Last | [SearchTEActivity.kt#L563](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L563) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | +| Identification+ Enrol Last | [SearchTEIViewModel.kt#L736](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L736) | Code change | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | +| Identification+ Enrol Last | [SearchTEList.kt#L271](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L271) | Code change | 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#L647](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L647) | 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 | [Injector.kt#L268](form/src/main/java/org/dhis2/form/di/Injector.kt#L268) | Code change | 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 change | 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 | +| Identification+ Enrol Last | [SimprintsSessionRepository.kt](commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt) | New file | Persists last identification session and pending Enrol Last state in shared preferences | +| Identification+ Enrol Last | [SimprintsResolvePendingEnrollmentActionUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt) | New file | Finds the configured register callout and prepares `REGISTER_LAST_BIOMETRICS` for the pending enrollment | +| Identification+ Enrol Last | [SimprintsIntentUtils.kt](commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt) | New file | Centralizes Simprints callout detection, request preparation, session extraction, and pending placeholder checks | +| Identification+ Enrol Last | [SimprintsSearchUtils.kt](commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt) | New file | Detects biometric identification queries and filters biometric GUIDs before carrying enrollment data | +| 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 change | Registers the confirm-identity SID launcher and waits for Simprints navigation before opening dashboards | +| Identification Confirm Identity | [SearchTEIViewModel.kt#L742](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L742) | Code change | Intercepts TEI openings from biometric search to launch Confirm Identity when required | +| Identification Confirm Identity | [SearchTEModule.java#L340](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L340) | Code change | DI for separate Simprints-specific components for Identification Confirm Identity | +| Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L27](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L27) | Code change | Passes Simprints search state into `SearchTEIViewModel` | +| 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 change | Overrides `moduleId` for Simprints identification intents with the current user's Org Unit value | +| Identification result ordering by score | [SearchTEIViewModel.kt#L490](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L490) | Code change | Routes biometric search loading through SID-order-aware lookup so results follow identification match score order | +| Identification result ordering by score | [SearchTEModule.java#L352](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L352) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | +| Identification result ordering by score | [SearchTeiViewModelFactory.kt#L28](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L28) | Code change | 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 | ### Releases -SimCapture [releases](https://github.com/Simprints/SimCapture/releases) have the following naming scheme: `SimCapture-DHIS2--fork-`. +SimCapture [releases](https://github.com/Simprints/SimCapture/releases) have the following naming +scheme: `SimCapture-DHIS2--fork-`. An example of a release APK name: `SimCapture-DHIS2-v3.3.1-fork-1-signed-release.apk`. ### GitHub Actions @@ -26,13 +58,13 @@ The workflows of this fork repo should start with `simcapture-`. The upstream `README` is preserved below. - # README # [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=dhis2_dhis2-android-capture-app&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=dhis2_dhis2-android-capture-app) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=dhis2_dhis2-android-capture-app&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=dhis2_dhis2-android-capture-app) -Check the [Wiki](https://github.com/dhis2/dhis2-android-capture-app/wiki) for information about how to build the project and its architecture **(WIP)** +Check the [Wiki](https://github.com/dhis2/dhis2-android-capture-app/wiki) for information about how +to build the project and its architecture **(WIP)** ### What is this repository for? ### From 8a699cd9de27abf93d69a795510d2742a5f3a8df Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 8 Apr 2026 15:25:19 +0100 Subject: [PATCH 09/17] Simprints ID+ Enrol Last: "carrier" intent action fix in tests --- .../src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt b/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt index cf6a7c35c32..721ce308a8b 100644 --- a/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt +++ b/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt @@ -677,7 +677,7 @@ class FormRepositoryImplTest { CustomIntentModel( uid = "register-last", name = "Register last", - packageName = "com.simprints.id.VERIFY", + packageName = "com.simprints.id.REGISTER", customIntentRequest = emptyList(), customIntentResponse = listOf( From 24bd34167c7787be87d5834235984ab7c6a8341e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 8 Apr 2026 15:26:52 +0100 Subject: [PATCH 10/17] Simprints ModuleID in Identification: org unit name naming fix --- .../commons/customintents/CustomIntentRepositoryImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt index 28d3d1f1bdd..daed91df555 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt @@ -108,9 +108,9 @@ class CustomIntentRepositoryImpl( if (customIntent.packageName() != simprintsIdentifyAction) { return this } - val orgUnitCode = currentUserOrgUnitName() + val orgUnitName = currentUserOrgUnitName() ?: return this - return this + (simprintsModuleIdKey to orgUnitCode) + return this + (simprintsModuleIdKey to orgUnitName) } private fun currentUserOrgUnitName(): String? = From 00914aa3a685e493b3878062a50e1fc6541dcb6a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 8 Apr 2026 17:31:49 +0100 Subject: [PATCH 11/17] Simprints-related changes README touchup: code additions marked separately from modifications --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 97369b2ffde..4fbb4566847 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,17 @@ 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-L35](README.md#L1-L35) | Code change | This section in README | -| Identification+ Enrol Last | [EnrollmentActivity.kt#L61](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L61) | Code change | 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 change | Delegates save-time Enrol Last resolution and result handling | -| Identification+ Enrol Last | [EnrollmentModule.kt#L155](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L155) | Code change | DI for separate Simprints-specific components for Identification+ Enrol Last | +| Docs: fork-specific README | [README.md#L1-L47](README.md#L1-L47) | Code change | This section in README | +| Identification+ Enrol Last | [EnrollmentActivity.kt#L61](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L61) | Code addition | Launches Enrol Last before completing a valid enrollment save and resumes finish after SID returns | +| 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#L155](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L155) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | | Identification+ Enrol Last | [SearchTEActivity.kt#L563](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L563) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | -| Identification+ Enrol Last | [SearchTEIViewModel.kt#L736](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L736) | Code change | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | -| Identification+ Enrol Last | [SearchTEList.kt#L271](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L271) | Code change | 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 | [SearchTEIViewModel.kt#L736](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L736) | Code addition | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | +| 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#L647](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L647) | 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 | [Injector.kt#L268](form/src/main/java/org/dhis2/form/di/Injector.kt#L268) | Code change | 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 change | Shows pending biometric placeholder state, clears it on user clear, and routes Simprints enrollment results | +| 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 | [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 | @@ -34,16 +34,16 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification+ Enrol Last | [SimprintsSearchUtils.kt](commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt) | New file | Detects biometric identification queries and filters biometric GUIDs before carrying enrollment data | | 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 change | Registers the confirm-identity SID launcher and waits for Simprints navigation before opening dashboards | -| Identification Confirm Identity | [SearchTEIViewModel.kt#L742](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L742) | Code change | Intercepts TEI openings from biometric search to launch Confirm Identity when required | -| Identification Confirm Identity | [SearchTEModule.java#L340](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L340) | Code change | DI for separate Simprints-specific components for Identification Confirm Identity | -| Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L27](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L27) | Code change | Passes Simprints search state into `SearchTEIViewModel` | +| 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#L742](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L742) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | +| Identification Confirm Identity | [SearchTEModule.java#L340](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L340) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | +| Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L27](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L27) | Code addition | Passes Simprints search state into `SearchTEIViewModel` | | 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 change | Overrides `moduleId` for Simprints identification intents with the current user's Org Unit value | -| Identification result ordering by score | [SearchTEIViewModel.kt#L490](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L490) | Code change | Routes biometric search loading through SID-order-aware lookup so results follow identification match score order | +| 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#L490](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L490) | 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#L352](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L352) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | -| Identification result ordering by score | [SearchTeiViewModelFactory.kt#L28](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L28) | Code change | Passes the ordered-result use case into `SearchTEIViewModel` | +| Identification result ordering by score | [SearchTeiViewModelFactory.kt#L28](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L28) | 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 | ### Releases From be18e59180f98d3a2e31f94e3464d72ca2b869b4 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Apr 2026 13:08:23 +0100 Subject: [PATCH 12/17] Simprints-specific VMs lifecycle integration --- .../simprints/SimprintsEnrollmentViewModel.kt | 3 ++- .../simprints/SimprintsSearchViewModel.kt | 3 ++- .../di/SimprintsEnrollmentViewModelFactory.kt | 24 +++++++++++++++++++ .../di/SimprintsSearchViewModelFactory.kt | 18 ++++++++++++++ .../usescases/enrollment/EnrollmentModule.kt | 13 ++++++++-- .../searchTrackEntity/SearchTEModule.java | 11 +++++---- .../SearchTeiViewModelFactory.kt | 6 +++-- 7 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/dhis2/simprints/di/SimprintsEnrollmentViewModelFactory.kt create mode 100644 app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index 26616a51c19..0ec0afdc981 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -2,6 +2,7 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent +import androidx.lifecycle.ViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -15,7 +16,7 @@ class SimprintsEnrollmentViewModel( private val sessionRepository: SimprintsSessionRepository, private val resultMapper: SimprintsCustomIntentResultMapper, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, -) { +) : ViewModel() { enum class RegisterLastResult { NONE, CONTINUE_FINISH, diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index a41b1c06d36..520b5290c91 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -2,6 +2,7 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent +import androidx.lifecycle.ViewModel import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase import org.dhis2.commons.simprints.utils.SimprintsSearchUtils @@ -9,7 +10,7 @@ import org.dhis2.commons.simprints.utils.SimprintsSearchUtils class SimprintsSearchViewModel( private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase, private val sessionRepository: SimprintsSessionRepository, -) { +) : ViewModel() { data class PendingDashboardNavigation( val teiUid: String, val programUid: String?, diff --git a/app/src/main/java/org/dhis2/simprints/di/SimprintsEnrollmentViewModelFactory.kt b/app/src/main/java/org/dhis2/simprints/di/SimprintsEnrollmentViewModelFactory.kt new file mode 100644 index 00000000000..d2c4e066863 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/di/SimprintsEnrollmentViewModelFactory.kt @@ -0,0 +1,24 @@ +package org.dhis2.simprints.di + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase +import org.dhis2.simprints.SimprintsCustomIntentResultMapper +import org.dhis2.simprints.SimprintsEnrollmentViewModel + +class SimprintsEnrollmentViewModelFactory( + private val simprintsD2Repository: SimprintsD2Repository, + private val resolvePendingEnrollmentAction: SimprintsResolvePendingEnrollmentActionUseCase, + private val sessionRepository: SimprintsSessionRepository, + private val resultMapper: SimprintsCustomIntentResultMapper, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T = + SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ) as T +} diff --git a/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt b/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt new file mode 100644 index 00000000000..feb4faa0c9c --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt @@ -0,0 +1,18 @@ +package org.dhis2.simprints.di + +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.SimprintsSearchViewModel + +class SimprintsSearchViewModelFactory( + private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase, + private val sessionRepository: SimprintsSessionRepository, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) as T +} diff --git a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt index 05cecef187f..ee2beaba097 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt @@ -1,6 +1,7 @@ package org.dhis2.usescases.enrollment import android.content.Context +import androidx.lifecycle.ViewModelProvider import dagger.Module import dagger.Provides import io.reactivex.processors.FlowableProcessor @@ -46,6 +47,7 @@ import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.dhis2.simprints.SimprintsCustomIntentResultMapper import org.dhis2.simprints.SimprintsEnrollmentViewModel +import org.dhis2.simprints.di.SimprintsEnrollmentViewModelFactory import org.dhis2.usescases.teiDashboard.TeiAttributesProvider import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 @@ -177,18 +179,25 @@ class EnrollmentModule( @Provides @PerActivity - fun provideSimprintsEnrollmentViewModel( + fun provideSimprintsEnrollmentViewModelFactory( simprintsD2Repository: SimprintsD2Repository, resolvePendingEnrollmentAction: SimprintsResolvePendingEnrollmentActionUseCase, simprintsSessionRepository: SimprintsSessionRepository, simprintsCustomIntentResultMapper: SimprintsCustomIntentResultMapper, - ) = SimprintsEnrollmentViewModel( + ) = SimprintsEnrollmentViewModelFactory( simprintsD2Repository = simprintsD2Repository, resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, sessionRepository = simprintsSessionRepository, resultMapper = simprintsCustomIntentResultMapper, ) + @Provides + @PerActivity + fun provideSimprintsEnrollmentViewModel( + simprintsEnrollmentViewModelFactory: SimprintsEnrollmentViewModelFactory, + ): SimprintsEnrollmentViewModel = + ViewModelProvider(activityContext as EnrollmentActivity, simprintsEnrollmentViewModelFactory)[SimprintsEnrollmentViewModel::class.java] + @Provides @PerActivity fun providePresenter( 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 5772617f765..d60a383a2d5 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -68,7 +68,7 @@ import org.dhis2.mobile.commons.reporting.CrashReportController; import org.dhis2.tracker.data.ProfilePictureProvider; import org.dhis2.ui.ThemeManager; -import org.dhis2.simprints.SimprintsSearchViewModel; +import org.dhis2.simprints.di.SimprintsSearchViewModelFactory; import org.dhis2.usescases.events.EventInfoProvider; import org.dhis2.usescases.searchTrackEntity.ui.mapper.TEICardMapper; import org.dhis2.usescases.tracker.TrackedEntityInstanceInfoProvider; @@ -363,11 +363,11 @@ SimprintsOrderSearchResultsByIdentifyResponseUseCase provideSimprintsOrderSearch @Provides @PerActivity - SimprintsSearchViewModel provideSimprintsSearchViewModel( + SimprintsSearchViewModelFactory provideSimprintsSearchViewModelFactory( SimprintsResolveConfirmIdentityCalloutUseCase resolveConfirmIdentityCalloutUseCase, SimprintsSessionRepository simprintsSessionRepository ) { - return new SimprintsSearchViewModel( + return new SimprintsSearchViewModelFactory( resolveConfirmIdentityCalloutUseCase, simprintsSessionRepository ); @@ -385,7 +385,7 @@ SearchTeiViewModelFactory providesViewModelFactory( DisplayNameProvider displayNameProvider, FilterManager filterManager, ProgramConfigurationRepository programConfigurationRepository, - SimprintsSearchViewModel simprintsSearchViewModel, + SimprintsSearchViewModelFactory simprintsSearchViewModelFactory, SimprintsOrderSearchResultsByIdentifyResponseUseCase orderSearchResultsByIdentifyResponse ) { return new SearchTeiViewModelFactory( @@ -406,7 +406,8 @@ SearchTeiViewModelFactory providesViewModelFactory( resourceManager, displayNameProvider, filterManager, - simprintsSearchViewModel, + (SearchTEActivity) moduleContext, + simprintsSearchViewModelFactory, orderSearchResultsByIdentifyResponse ); } 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 d87e691f3d3..63d2a8a8c25 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt @@ -10,6 +10,7 @@ import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.simprints.SimprintsSearchViewModel +import org.dhis2.simprints.di.SimprintsSearchViewModelFactory class SearchTeiViewModelFactory( private val searchRepository: SearchRepository, @@ -24,7 +25,8 @@ class SearchTeiViewModelFactory( private val resourceManager: ResourceManager, private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, - private val simprintsSearchViewModel: SimprintsSearchViewModel, + private val searchActivity: SearchTEActivity, + private val simprintsSearchViewModelFactory: SimprintsSearchViewModelFactory, private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = @@ -41,7 +43,7 @@ class SearchTeiViewModelFactory( resourceManager, displayNameProvider, filterManager, - simprintsSearchViewModel, + ViewModelProvider(searchActivity, simprintsSearchViewModelFactory)[SimprintsSearchViewModel::class.java], orderSearchResultsByIdentifyResponse, ) as T } From 40a7295aa51cfd63218fe0d5e3c20652cbe4c893 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Apr 2026 13:09:37 +0100 Subject: [PATCH 13/17] Simprints Enrol Last: persistence through configuration changes --- .../searchTrackEntity/SearchTEIViewModel.kt | 20 +++++++++++++++- .../SearchTEIViewModelTest.kt | 23 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 9f2803c7f24..be4d1ae6a05 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -48,6 +48,7 @@ import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentif 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 import org.dhis2.form.model.FieldUiModelImpl import org.dhis2.form.ui.intent.FormIntent import org.dhis2.form.ui.provider.DisplayNameProvider @@ -1128,11 +1129,28 @@ class SearchTEIViewModel( viewModelScope.launch { val fieldUiModels = searchRepositoryKt.searchParameters(programUid, teiTypeUid) - searchParametersUiState = searchParametersUiState.copy(items = fieldUiModels) + searchParametersUiState = searchParametersUiState.copy( + items = preserveExistingSearchParameterValues(fieldUiModels), + ) refreshSimprintsUiState() } } + private fun preserveExistingSearchParameterValues(fieldUiModels: List): List { + val currentItemsByUid = searchParametersUiState.items.associateBy(FieldUiModel::uid) + return fieldUiModels.map { fieldUiModel -> + val currentItem = currentItemsByUid[fieldUiModel.uid] + if (fieldUiModel is FieldUiModelImpl && currentItem is FieldUiModelImpl) { + fieldUiModel.copy( + value = currentItem.value, + displayName = currentItem.displayName, + ) + } else { + fieldUiModel + } + } + } + fun onParameterIntent(formIntent: FormIntent) = when (formIntent) { is FormIntent.OnTextChange -> { 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 1df3b5ab6d1..7add0c4a43e 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -593,6 +593,29 @@ class SearchTEIViewModelTest { assertTrue(viewModel.isSimprintsUseLastBiometricsLabel.value == true) } + @Test + fun `Should preserve entered search parameter values when parameters are refetched`() = + runTest { + whenever(repositoryKt.searchParameters(initialProgram, "teiTypeUid")) doReturn + listOf(simprintsBiometricSearchField()) + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = + listOf( + simprintsBiometricSearchField().copy( + value = "guid-1", + displayName = "guid-1", + ), + ), + ) + + viewModel.fetchSearchParameters(initialProgram, "teiTypeUid") + testingDispatcher.scheduler.advanceUntilIdle() + + assertEquals("guid-1", viewModel.searchParametersUiState.items.single().value) + assertEquals("guid-1", viewModel.searchParametersUiState.items.single().displayName) + } + @Test fun `Should enroll on click`() { viewModel.onEnrollClick() From 4ec7a812265248bee55bc04f3d1eb76427060eec Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Apr 2026 13:26:10 +0100 Subject: [PATCH 14/17] Simprints-specific VMs: thread safety improvements --- .../simprints/SimprintsEnrollmentViewModel.kt | 13 ++++++++----- .../dhis2/simprints/SimprintsSearchViewModel.kt | 15 +++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index 0ec0afdc981..104e5268215 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -3,6 +3,8 @@ 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 kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -10,6 +12,7 @@ import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase +@OptIn(ExperimentalAtomicApi::class) class SimprintsEnrollmentViewModel( private val simprintsD2Repository: SimprintsD2Repository, private val resolvePendingEnrollmentAction: SimprintsResolvePendingEnrollmentActionUseCase, @@ -23,7 +26,8 @@ class SimprintsEnrollmentViewModel( ERROR, } - private var pendingAction: SimprintsResolvePendingEnrollmentActionUseCase.PendingEnrollmentAction? = null + private val pendingAction = + AtomicReference(null) suspend fun onFinishRequested( isNewEnrollment: Boolean, @@ -41,7 +45,7 @@ class SimprintsEnrollmentViewModel( ) } ?: return null - pendingAction = resolvedAction + pendingAction.store(resolvedAction) return resolvedAction.callout.launchIntent } @@ -50,8 +54,7 @@ class SimprintsEnrollmentViewModel( data: Intent?, teiUid: String?, ): RegisterLastResult { - val resolvedAction = pendingAction ?: return RegisterLastResult.NONE - pendingAction = null + val resolvedAction = pendingAction.exchange(null) ?: return RegisterLastResult.NONE val saved = if (resultCode == RESULT_OK && teiUid != null) { @@ -81,6 +84,6 @@ class SimprintsEnrollmentViewModel( } fun onRegisterLastLaunchFailed() { - pendingAction = null + pendingAction.store(null) } } diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index 520b5290c91..db1c20337c9 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -3,10 +3,13 @@ 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.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase import org.dhis2.commons.simprints.utils.SimprintsSearchUtils +@OptIn(ExperimentalAtomicApi::class) class SimprintsSearchViewModel( private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase, private val sessionRepository: SimprintsSessionRepository, @@ -27,7 +30,7 @@ class SimprintsSearchViewModel( ) : DashboardAction() } - private var pendingDashboardNavigation: PendingDashboardNavigation? = null + private val pendingDashboardNavigation = AtomicReference(null) suspend fun onDashboardRequested( searchFields: List, @@ -57,8 +60,7 @@ class SimprintsSearchViewModel( return DashboardAction.OpenDashboard(navigation) } - pendingDashboardNavigation = - navigation + pendingDashboardNavigation.store(navigation) sessionRepository.clear() return DashboardAction.LaunchConfirmIdentity(confirmIdentityIntent) } @@ -80,14 +82,11 @@ class SimprintsSearchViewModel( } fun onConfirmIdentityResult(resultCode: Int): PendingDashboardNavigation? = - pendingDashboardNavigation + pendingDashboardNavigation.exchange(null) ?.takeIf { resultCode == RESULT_OK } - .also { - pendingDashboardNavigation = null - } fun onConfirmIdentityLaunchFailed() { - pendingDashboardNavigation = null + pendingDashboardNavigation.store(null) } fun shouldUseLastBiometricsLabel(searchFields: List): Boolean { From d725805c79ae2b5b762a32b64feaed5b6347f8c9 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Apr 2026 13:50:51 +0100 Subject: [PATCH 15/17] Simprints search: separate pending session cleanup from label check --- .../simprints/SimprintsSearchViewModel.kt | 11 +++++----- .../searchTrackEntity/SearchTEIViewModel.kt | 6 +++--- .../simprints/SimprintsSearchViewModelTest.kt | 20 +++++++++++++++++-- .../SearchTEIViewModelTest.kt | 3 +++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index db1c20337c9..9e1a97edd08 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -89,17 +89,18 @@ class SimprintsSearchViewModel( pendingDashboardNavigation.store(null) } - fun shouldUseLastBiometricsLabel(searchFields: List): Boolean { + fun clearPendingSessionIfNeeded(searchFields: List) { val searchState = SimprintsSearchUtils.searchState(searchFields) - val hasPendingSession = sessionRepository.hasPendingSession() - if (hasPendingSession && searchState.shouldClearPendingSession) { + if (sessionRepository.hasPendingSession() && searchState.shouldClearPendingSession) { sessionRepository.clear() - return false } + } + fun shouldUseLastBiometricsLabel(searchFields: List): Boolean { + val searchState = SimprintsSearchUtils.searchState(searchFields) return SimprintsSearchUtils.shouldUseLastBiometricsLabel( searchState = searchState, - hasPendingSession = hasPendingSession, + hasPendingSession = sessionRepository.hasPendingSession(), ) } } 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 be4d1ae6a05..cb431d18684 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -804,10 +804,10 @@ class SearchTEIViewModel( } fun refreshSimprintsUiState() { + val searchFields = getSimprintsSearchFields() + simprintsSearchViewModel.clearPendingSessionIfNeeded(searchFields) _isSimprintsUseLastBiometricsLabel.postValue( - simprintsSearchViewModel.shouldUseLastBiometricsLabel( - searchFields = getSimprintsSearchFields(), - ), + simprintsSearchViewModel.shouldUseLastBiometricsLabel(searchFields), ) } diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt index 0a746c97a11..39e372dcdd7 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt @@ -171,7 +171,23 @@ class SimprintsSearchViewModelTest { } @Test - fun `shouldUseLastBiometricsLabel should clear pending session when query is no longer biometric`() { + fun `clearPendingSessionIfNeeded should clear pending session when query is no longer biometric`() { + whenever(sessionRepository.hasPendingSession()) doReturn true + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + ) + + viewModel.clearPendingSessionIfNeeded( + searchFields = listOf(textField(uid = "name", value = "Name")), + ) + + verify(sessionRepository).clear() + } + + @Test + fun `shouldUseLastBiometricsLabel should return false for non biometric search`() { whenever(sessionRepository.hasPendingSession()) doReturn true val viewModel = SimprintsSearchViewModel( @@ -185,7 +201,7 @@ class SimprintsSearchViewModelTest { ) assertFalse(shouldUseLastBiometricsLabel) - verify(sessionRepository).clear() + verify(sessionRepository, never()).clear() } @Test 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 7add0c4a43e..011d95def48 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -53,6 +53,7 @@ import org.junit.Test import org.maplibre.geojson.BoundingBox import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.times @@ -587,10 +588,12 @@ class SearchTEIViewModelTest { @Test fun `Should refresh Simprints last biometrics label state`() { whenever(simprintsSearchViewModel.shouldUseLastBiometricsLabel(any())) doReturn true + clearInvocations(simprintsSearchViewModel) viewModel.refreshSimprintsUiState() assertTrue(viewModel.isSimprintsUseLastBiometricsLabel.value == true) + verify(simprintsSearchViewModel).clearPendingSessionIfNeeded(any()) } @Test From 4ee216839a962a6099bd11aa13bba14721591635 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Apr 2026 15:58:40 +0100 Subject: [PATCH 16/17] Simprints D2 repository IO threading contained --- .../simprints/SimprintsEnrollmentViewModel.kt | 28 ++++++------- .../searchTrackEntity/SearchTEModule.java | 16 ++------ .../SimprintsEnrollmentViewModelTest.kt | 8 ---- .../repository/SimprintsD2Repository.kt | 34 +++++++++++----- ...rSearchResultsByIdentifyResponseUseCase.kt | 29 ++++++-------- ...ntsResolveConfirmIdentityCalloutUseCase.kt | 34 +++++++--------- ...tsResolvePendingEnrollmentActionUseCase.kt | 39 ++++++++----------- .../repository/SimprintsD2RepositoryTest.kt | 20 +++++----- ...rchResultsByIdentifyResponseUseCaseTest.kt | 2 - ...esolveConfirmIdentityCalloutUseCaseTest.kt | 2 - ...solvePendingEnrollmentActionUseCaseTest.kt | 2 - 11 files changed, 94 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index 104e5268215..7b216de212f 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -5,9 +5,6 @@ import android.content.Intent import androidx.lifecycle.ViewModel import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase @@ -18,7 +15,6 @@ class SimprintsEnrollmentViewModel( private val resolvePendingEnrollmentAction: SimprintsResolvePendingEnrollmentActionUseCase, private val sessionRepository: SimprintsSessionRepository, private val resultMapper: SimprintsCustomIntentResultMapper, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel() { enum class RegisterLastResult { NONE, @@ -58,19 +54,17 @@ class SimprintsEnrollmentViewModel( val saved = if (resultCode == RESULT_OK && teiUid != null) { - withContext(ioDispatcher) { - val value = - resultMapper.map( - responseData = resolvedAction.callout.responseData, - data = data, - ) ?: return@withContext false - simprintsD2Repository.saveTrackedEntityAttributeValue( - teiUid = teiUid, - attributeUid = resolvedAction.fieldUid, - value = value, - ) - true - } + val value = + resultMapper.map( + responseData = resolvedAction.callout.responseData, + data = data, + ) ?: return RegisterLastResult.ERROR + simprintsD2Repository.saveTrackedEntityAttributeValue( + teiUid = teiUid, + attributeUid = resolvedAction.fieldUid, + value = value, + ) + true } else { false } 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 d60a383a2d5..bb531b8105e 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -340,25 +340,17 @@ SimprintsD2Repository provideSimprintsD2Repository( @Provides @PerActivity SimprintsResolveConfirmIdentityCalloutUseCase provideSimprintsResolveConfirmIdentityCalloutUseCase( - SimprintsD2Repository simprintsD2Repository, - DispatcherProvider dispatcherProvider + SimprintsD2Repository simprintsD2Repository ) { - return new SimprintsResolveConfirmIdentityCalloutUseCase( - simprintsD2Repository, - dispatcherProvider.io() - ); + return new SimprintsResolveConfirmIdentityCalloutUseCase(simprintsD2Repository); } @Provides @PerActivity SimprintsOrderSearchResultsByIdentifyResponseUseCase provideSimprintsOrderSearchResultsByIdentifyResponseUseCase( - SimprintsD2Repository simprintsD2Repository, - DispatcherProvider dispatcherProvider + SimprintsD2Repository simprintsD2Repository ) { - return new SimprintsOrderSearchResultsByIdentifyResponseUseCase( - simprintsD2Repository, - dispatcherProvider.io() - ); + return new SimprintsOrderSearchResultsByIdentifyResponseUseCase(simprintsD2Repository); } @Provides diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt index c8250f817bd..f644caf27af 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt @@ -2,7 +2,6 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository @@ -37,7 +36,6 @@ class SimprintsEnrollmentViewModelTest { resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, sessionRepository = sessionRepository, resultMapper = resultMapper, - ioDispatcher = StandardTestDispatcher(testScheduler), ) val launchIntent = @@ -60,7 +58,6 @@ class SimprintsEnrollmentViewModelTest { resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, sessionRepository = sessionRepository, resultMapper = resultMapper, - ioDispatcher = StandardTestDispatcher(testScheduler), ) val launchIntent = @@ -109,7 +106,6 @@ class SimprintsEnrollmentViewModelTest { resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, sessionRepository = sessionRepository, resultMapper = resultMapper, - ioDispatcher = StandardTestDispatcher(testScheduler), ) val preparedIntent = @@ -169,7 +165,6 @@ class SimprintsEnrollmentViewModelTest { resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, sessionRepository = sessionRepository, resultMapper = resultMapper, - ioDispatcher = StandardTestDispatcher(testScheduler), ) viewModel.onFinishRequested( @@ -217,7 +212,6 @@ class SimprintsEnrollmentViewModelTest { resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, sessionRepository = sessionRepository, resultMapper = resultMapper, - ioDispatcher = StandardTestDispatcher(testScheduler), ) viewModel.onFinishRequested( @@ -244,7 +238,6 @@ class SimprintsEnrollmentViewModelTest { resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, sessionRepository = sessionRepository, resultMapper = resultMapper, - ioDispatcher = StandardTestDispatcher(testScheduler), ) val result = @@ -282,7 +275,6 @@ class SimprintsEnrollmentViewModelTest { resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, sessionRepository = sessionRepository, resultMapper = resultMapper, - ioDispatcher = StandardTestDispatcher(testScheduler), ) viewModel.onFinishRequested( diff --git a/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt index 45404eaa049..a9bd8d7f05b 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt @@ -1,9 +1,15 @@ package org.dhis2.commons.simprints.repository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.hisp.dhis.android.core.D2 -class SimprintsD2Repository( +class SimprintsD2Repository @JvmOverloads constructor( private val d2: D2, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism( + IO_PARALLEL_THREADS + ), ) { data class EnrollmentContext( val teiUid: String, @@ -11,7 +17,7 @@ class SimprintsD2Repository( val orgUnitUid: String?, ) - fun getEnrollmentContext(enrollmentUid: String): EnrollmentContext? = + suspend fun getEnrollmentContext(enrollmentUid: String): EnrollmentContext? = onIo { d2 .enrollmentModule() .enrollments() @@ -19,13 +25,14 @@ class SimprintsD2Repository( .blockingGet() ?.let { enrollment -> EnrollmentContext( - teiUid = enrollment.trackedEntityInstance() ?: return null, + teiUid = enrollment.trackedEntityInstance() ?: return@onIo null, programUid = enrollment.program(), orgUnitUid = enrollment.organisationUnit(), ) } + } - fun getProgramAttributeUids(programUid: String): List = + suspend fun getProgramAttributeUids(programUid: String): List = onIo { d2 .programModule() .programTrackedEntityAttributes() @@ -33,8 +40,9 @@ class SimprintsD2Repository( .eq(programUid) .blockingGet() .mapNotNull { it.trackedEntityAttribute()?.uid() } + } - fun getTrackedEntityTypeAttributeUids(teiUid: String): List = + suspend fun getTrackedEntityTypeAttributeUids(teiUid: String): List = onIo { d2 .trackedEntityModule() .trackedEntityInstances() @@ -50,27 +58,35 @@ class SimprintsD2Repository( .blockingGet() .mapNotNull { it.trackedEntityAttribute()?.uid() } } ?: emptyList() + } - fun getTrackedEntityAttributeValue( + suspend fun getTrackedEntityAttributeValue( teiUid: String, attributeUid: String, - ): String? = + ): String? = onIo { d2 .trackedEntityModule() .trackedEntityAttributeValues() .value(attributeUid, teiUid) .blockingGet() ?.value() + } - fun saveTrackedEntityAttributeValue( + suspend fun saveTrackedEntityAttributeValue( teiUid: String, attributeUid: String, value: String, - ) { + ) = onIo { d2 .trackedEntityModule() .trackedEntityAttributeValues() .value(attributeUid, teiUid) .blockingSet(value) } + + private suspend fun onIo(block: () -> T): T = withContext(ioDispatcher) { block() } + + private companion object { + private const val IO_PARALLEL_THREADS = 4 + } } diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt index 616e3386bd3..33b712f230a 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt @@ -1,8 +1,5 @@ package org.dhis2.commons.simprints.usecases -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.commons.simprints.utils.SimprintsSearchUtils @@ -10,7 +7,6 @@ import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem class SimprintsOrderSearchResultsByIdentifyResponseUseCase( private val simprintsD2Repository: SimprintsD2Repository, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { private data class IdentifyResponseOrder( val fieldUid: String, @@ -23,18 +19,17 @@ class SimprintsOrderSearchResultsByIdentifyResponseUseCase( searchFields: Iterable, queryData: Map?>?, searchTrackedEntities: suspend () -> List, - ): List? = - withContext(ioDispatcher) { - val order = getIdentifyResponseOrder(searchFields, queryData) ?: return@withContext null - searchTrackedEntities() - .map { trackedEntity -> - trackedEntity to getMatchedGuid(trackedEntity, order) - }.sortedBy { (_, guid) -> - guid?.let(order.orderByGuid::get) ?: Int.MAX_VALUE - }.map { (trackedEntity, _) -> - trackedEntity - } - } + ): List? { + val order = getIdentifyResponseOrder(searchFields, queryData) ?: return null + return searchTrackedEntities() + .map { trackedEntity -> + trackedEntity to getMatchedGuid(trackedEntity, order) + }.sortedBy { (_, guid) -> + guid?.let(order.orderByGuid::get) ?: Int.MAX_VALUE + }.map { (trackedEntity, _) -> + trackedEntity + } + } private fun getIdentifyResponseOrder( searchFields: Iterable, @@ -51,7 +46,7 @@ class SimprintsOrderSearchResultsByIdentifyResponseUseCase( } } - private fun getMatchedGuid( + private suspend fun getMatchedGuid( searchItem: TrackedEntitySearchItem, order: IdentifyResponseOrder, ): String? { 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 8ac81155d7f..41a2f1c9256 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 @@ -1,37 +1,31 @@ package org.dhis2.commons.simprints.usecases -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.commons.simprints.utils.SimprintsSearchUtils class SimprintsResolveConfirmIdentityCalloutUseCase( private val simprintsD2Repository: SimprintsD2Repository, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { suspend operator fun invoke( teiUid: String, searchFields: Iterable, sessionId: String, ): SimprintsIntentUtils.PreparedCallout? = - withContext(ioDispatcher) { - searchFields.firstNotNullOfOrNull { field -> - val customIntent = field.customIntent - val selectedGuid = simprintsD2Repository.getTrackedEntityAttributeValue(teiUid, field.uid) - when { - field.value.isNullOrBlank() -> null - customIntent == null -> null - !SimprintsIntentUtils.isIdentifyCallout(customIntent) -> null - selectedGuid.isNullOrBlank() -> null - else -> - SimprintsIntentUtils.prepareConfirmIdentityCallout( - customIntent = customIntent, - sessionId = sessionId, - selectedGuid = selectedGuid, - ) - } + searchFields.firstNotNullOfOrNull { field -> + val customIntent = field.customIntent + val selectedGuid = simprintsD2Repository.getTrackedEntityAttributeValue(teiUid, field.uid) + when { + field.value.isNullOrBlank() -> null + customIntent == null -> null + !SimprintsIntentUtils.isIdentifyCallout(customIntent) -> null + selectedGuid.isNullOrBlank() -> null + else -> + SimprintsIntentUtils.prepareConfirmIdentityCallout( + customIntent = customIntent, + sessionId = sessionId, + selectedGuid = selectedGuid, + ) } } } diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt index 3ef5be1282a..6a83247c1a9 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCase.kt @@ -1,8 +1,5 @@ package org.dhis2.commons.simprints.usecases -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.mobile.commons.customintents.CustomIntentRepository @@ -11,7 +8,6 @@ import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel class SimprintsResolvePendingEnrollmentActionUseCase( private val simprintsD2Repository: SimprintsD2Repository, private val customIntentRepository: CustomIntentRepository, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { data class PendingEnrollmentAction( val fieldUid: String, @@ -21,30 +17,29 @@ class SimprintsResolvePendingEnrollmentActionUseCase( suspend operator fun invoke( enrollmentUid: String, sessionId: String, - ): PendingEnrollmentAction? = - withContext(ioDispatcher) { - val enrollment = - simprintsD2Repository.getEnrollmentContext(enrollmentUid) - ?: return@withContext null - enrollment - .attributeUids() - .firstNotNullOfOrNull { attributeUid -> - resolvePendingEnrollmentAction( - attributeUid = attributeUid, - teiUid = enrollment.teiUid, - orgUnitUid = enrollment.orgUnitUid, - sessionId = sessionId, - ) - } - } + ): PendingEnrollmentAction? { + val enrollment = + simprintsD2Repository.getEnrollmentContext(enrollmentUid) + ?: return null + return enrollment + .attributeUids() + .firstNotNullOfOrNull { attributeUid -> + resolvePendingEnrollmentAction( + attributeUid = attributeUid, + teiUid = enrollment.teiUid, + orgUnitUid = enrollment.orgUnitUid, + sessionId = sessionId, + ) + } + } - private fun SimprintsD2Repository.EnrollmentContext.attributeUids(): List = + private suspend fun SimprintsD2Repository.EnrollmentContext.attributeUids(): List = buildList { programUid?.let { addAll(simprintsD2Repository.getProgramAttributeUids(it)) } addAll(simprintsD2Repository.getTrackedEntityTypeAttributeUids(teiUid)) } - private fun resolvePendingEnrollmentAction( + private suspend fun resolvePendingEnrollmentAction( attributeUid: String, teiUid: String, orgUnitUid: String?, diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt index 640383229ae..63e32d5dc14 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt @@ -1,5 +1,7 @@ package org.dhis2.commons.simprints.repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector import org.hisp.dhis.android.core.common.ObjectWithUid @@ -23,10 +25,10 @@ import org.mockito.kotlin.whenever class SimprintsD2RepositoryTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) - private val repository = SimprintsD2Repository(d2) + private val repository = SimprintsD2Repository(d2, Dispatchers.Unconfined) @Test - fun `getEnrollmentContext should map enrollment fields`() { + fun `getEnrollmentContext should map enrollment fields`() = runBlocking { whenever( d2 .enrollmentModule() @@ -55,7 +57,7 @@ class SimprintsD2RepositoryTest { } @Test - fun `getEnrollmentContext should return null when enrollment is missing`() { + fun `getEnrollmentContext should return null when enrollment is missing`() = runBlocking { whenever( d2 .enrollmentModule() @@ -70,7 +72,7 @@ class SimprintsD2RepositoryTest { } @Test - fun `getEnrollmentContext should return null when tracked entity instance is missing`() { + fun `getEnrollmentContext should return null when tracked entity instance is missing`() = runBlocking { whenever( d2 .enrollmentModule() @@ -91,7 +93,7 @@ class SimprintsD2RepositoryTest { } @Test - fun `getProgramAttributeUids should map non null tracked entity attributes`() { + fun `getProgramAttributeUids should map non null tracked entity attributes`() = runBlocking { val programFilter: StringFilterConnector = mock() val programAttributeRepository: ProgramTrackedEntityAttributeCollectionRepository = mock() @@ -123,7 +125,7 @@ class SimprintsD2RepositoryTest { } @Test - fun `getTrackedEntityTypeAttributeUids should map type attribute uids`() { + fun `getTrackedEntityTypeAttributeUids should map type attribute uids`() = runBlocking { whenever( d2 .trackedEntityModule() @@ -173,7 +175,7 @@ class SimprintsD2RepositoryTest { } @Test - fun `getTrackedEntityTypeAttributeUids should return empty list when tracked entity instance is missing`() { + fun `getTrackedEntityTypeAttributeUids should return empty list when tracked entity instance is missing`() = runBlocking { whenever( d2 .trackedEntityModule() @@ -188,7 +190,7 @@ class SimprintsD2RepositoryTest { } @Test - fun `getTrackedEntityAttributeValue should return stored attribute value`() { + fun `getTrackedEntityAttributeValue should return stored attribute value`() = runBlocking { whenever( d2 .trackedEntityModule() @@ -213,7 +215,7 @@ class SimprintsD2RepositoryTest { } @Test - fun `saveTrackedEntityAttributeValue should write attribute value`() { + fun `saveTrackedEntityAttributeValue should write attribute value`() = runBlocking { val attributeValueRepository: TrackedEntityAttributeValueObjectRepository = mock() whenever( d2 diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest.kt index 90569abb08c..3895bb27c81 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest.kt @@ -1,6 +1,5 @@ package org.dhis2.commons.simprints.usecases -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.utils.SimprintsSearchUtils @@ -23,7 +22,6 @@ class SimprintsOrderSearchResultsByIdentifyResponseUseCaseTest { private val useCase = SimprintsOrderSearchResultsByIdentifyResponseUseCase( simprintsD2Repository = repository, - ioDispatcher = Dispatchers.Unconfined, ) @Test 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 43727a6ba26..92060fe2bd7 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 @@ -1,7 +1,6 @@ package org.dhis2.commons.simprints.usecases import android.content.Intent -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.utils.SimprintsSearchUtils @@ -35,7 +34,6 @@ class SimprintsResolveConfirmIdentityCalloutUseCaseTest { val useCase = SimprintsResolveConfirmIdentityCalloutUseCase( simprintsD2Repository = repository, - ioDispatcher = Dispatchers.Unconfined, ) val intentActions = mutableListOf() diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt index 916b42e00ed..cff09317b9c 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolvePendingEnrollmentActionUseCaseTest.kt @@ -1,7 +1,6 @@ package org.dhis2.commons.simprints.usecases import android.content.Intent -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.mobile.commons.customintents.CustomIntentRepository @@ -54,7 +53,6 @@ class SimprintsResolvePendingEnrollmentActionUseCaseTest { SimprintsResolvePendingEnrollmentActionUseCase( simprintsD2Repository = repository, customIntentRepository = customIntentRepository, - ioDispatcher = Dispatchers.Unconfined, ) val intentActions = mutableListOf() From 52ece4a79c91bb4e8900e32bd055a792cd7a96b0 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 Apr 2026 16:11:30 +0100 Subject: [PATCH 17/17] Simprints changes README updated --- README.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4fbb4566847..7b6896c4bfb 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,16 @@ 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-L47](README.md#L1-L47) | Code change | This section in README | +| Docs: fork-specific README | [README.md#L1-L51](README.md#L1-L51) | Code change | This section in README | | Identification+ Enrol Last | [EnrollmentActivity.kt#L61](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L61) | Code addition | Launches Enrol Last before completing a valid enrollment save and resumes finish after SID returns | | 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#L155](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L155) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | -| Identification+ Enrol Last | [SearchTEActivity.kt#L563](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L563) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | -| Identification+ Enrol Last | [SearchTEIViewModel.kt#L736](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L736) | Code addition | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | +| 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#L647](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L647) | Code change | Switches the new TEI enrollment button text to the last-biometrics label when a pending biometric session exists | +| 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 | [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 | @@ -35,15 +37,16 @@ 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#L742](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L742) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | -| Identification Confirm Identity | [SearchTEModule.java#L340](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L340) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | -| Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L27](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L27) | Code addition | Passes Simprints search state into `SearchTEIViewModel` | +| 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 | [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#L490](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L490) | 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#L352](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L352) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | -| Identification result ordering by score | [SearchTeiViewModelFactory.kt#L28](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L28) | Code addition | Passes the ordered-result use case into `SearchTEIViewModel` | +| 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 | +| 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 | ### Releases