Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ab6943a
Simprints Confirm Identity & Enrol Last: features in dedicated files
alex-vt Apr 6, 2026
1b259b4
Simprints Confirm Identity & Enrol Last: integration into existing code
alex-vt Apr 6, 2026
7a75164
Simprints Confirm Identity & Enrol Last: ktlint on added files
alex-vt Apr 7, 2026
7f9d3e2
Simprints Confirm Identity & Enrol Last: tests
alex-vt Apr 7, 2026
742640f
Simprints Confirm Identity & Enrol Last: pending Enrol Last more narr…
alex-vt Apr 7, 2026
73e8192
Simprints Identification intent moduleId to be user's Org Unit name
alex-vt Apr 7, 2026
9c3b9a5
Simprints Identification search result order preservation by match score
alex-vt Apr 8, 2026
3c276f0
Simprints Identification feature descriptions in README
alex-vt Apr 8, 2026
8a699cd
Simprints ID+ Enrol Last: "carrier" intent action fix in tests
alex-vt Apr 8, 2026
24bd341
Simprints ModuleID in Identification: org unit name naming fix
alex-vt Apr 8, 2026
00914aa
Simprints-related changes README touchup: code additions marked separ…
alex-vt Apr 8, 2026
be18e59
Simprints-specific VMs lifecycle integration
alex-vt Apr 16, 2026
40a7295
Simprints Enrol Last: persistence through configuration changes
alex-vt Apr 16, 2026
4ec7a81
Simprints-specific VMs: thread safety improvements
alex-vt Apr 16, 2026
d725805
Simprints search: separate pending session cleanup from label check
alex-vt Apr 16, 2026
4ee2168
Simprints D2 repository IO threading contained
alex-vt Apr 16, 2026
52ece4a
Simprints changes README updated
alex-vt Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 47 additions & 12 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<CustomIntentResponseDataModel>?,
data: Intent?,
): String? =
contract
.mapIntentResponseData(responseData, data)
?.takeUnless(List<String>::isEmpty)
?.joinToString(separator = ",")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.dhis2.simprints

import android.app.Activity.RESULT_OK
import android.content.Intent
import androidx.lifecycle.ViewModel
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import org.dhis2.commons.simprints.repository.SimprintsD2Repository
import org.dhis2.commons.simprints.repository.SimprintsSessionRepository
import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase

@OptIn(ExperimentalAtomicApi::class)
class SimprintsEnrollmentViewModel(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed on slack should extend android VM

private val simprintsD2Repository: SimprintsD2Repository,
private val resolvePendingEnrollmentAction: SimprintsResolvePendingEnrollmentActionUseCase,
private val sessionRepository: SimprintsSessionRepository,
private val resultMapper: SimprintsCustomIntentResultMapper,
) : ViewModel() {
enum class RegisterLastResult {
NONE,
CONTINUE_FINISH,
ERROR,
}

private val pendingAction =
AtomicReference<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.store(resolvedAction)
return resolvedAction.callout.launchIntent
}

suspend fun onRegisterLastResult(
resultCode: Int,
data: Intent?,
teiUid: String?,
): RegisterLastResult {
val resolvedAction = pendingAction.exchange(null) ?: return RegisterLastResult.NONE

val saved =
if (resultCode == RESULT_OK && teiUid != null) {
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
}

if (saved) {
sessionRepository.clear()
return RegisterLastResult.CONTINUE_FINISH
}

return RegisterLastResult.ERROR
}

fun onRegisterLastLaunchFailed() {
pendingAction.store(null)
}
}
106 changes: 106 additions & 0 deletions app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed on slack should extend android VM

private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase,
private val sessionRepository: SimprintsSessionRepository,
) : ViewModel() {
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 val pendingDashboardNavigation = AtomicReference<PendingDashboardNavigation?>(null)

suspend fun onDashboardRequested(
searchFields: List<SimprintsSearchUtils.SearchField>,
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.store(navigation)
sessionRepository.clear()
return DashboardAction.LaunchConfirmIdentity(confirmIdentityIntent)
}

fun prepareEnrollmentQueryData(
searchFields: List<SimprintsSearchUtils.SearchField>,
queryData: Map<String, List<String>?>,
): HashMap<String, List<String>> {
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.exchange(null)
?.takeIf { resultCode == RESULT_OK }

fun onConfirmIdentityLaunchFailed() {
pendingDashboardNavigation.store(null)
}

fun clearPendingSessionIfNeeded(searchFields: List<SimprintsSearchUtils.SearchField>) {
val searchState = SimprintsSearchUtils.searchState(searchFields)
if (sessionRepository.hasPendingSession() && searchState.shouldClearPendingSession) {
sessionRepository.clear()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearing the session state in a function called ShouldUseLastX is a bit confusing as it feels like a side effect. You call this to find out if you should use the label, but then without knowing it the session is cleared. I'd either change the name to be more explicit, or just split this function into 2:

fun clearPendingSessionIfNeeded(searchFields: List<SimprintsSearchUtils.SearchField>) {
val searchState = SimprintsSearchUtils.searchState(searchFields)
if (sessionRepository.hasPendingSession() && searchState.shouldClearPendingSession) {
sessionRepository.clear()
}
}

fun shouldUseLastBiometricsLabel(searchFields: List<SimprintsSearchUtils.SearchField>): Boolean {
val searchState = SimprintsSearchUtils.searchState(searchFields)
return SimprintsSearchUtils.shouldUseLastBiometricsLabel(
searchState = searchState,
hasPendingSession = sessionRepository.hasPendingSession(),
)
}

}
}

fun shouldUseLastBiometricsLabel(searchFields: List<SimprintsSearchUtils.SearchField>): Boolean {
val searchState = SimprintsSearchUtils.searchState(searchFields)
return SimprintsSearchUtils.shouldUseLastBiometricsLabel(
searchState = searchState,
hasPendingSession = sessionRepository.hasPendingSession(),
)
}
}
Original file line number Diff line number Diff line change
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T =
SimprintsEnrollmentViewModel(
simprintsD2Repository = simprintsD2Repository,
resolvePendingEnrollmentAction = resolvePendingEnrollmentAction,
sessionRepository = sessionRepository,
resultMapper = resultMapper,
) as T
}
Original file line number Diff line number Diff line change
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T =
SimprintsSearchViewModel(
resolveConfirmIdentityCallout = resolveConfirmIdentityCallout,
sessionRepository = sessionRepository,
) as T
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 :
Expand All @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
Loading