From 2d5b825d9bffc1d9b33fe349de64c12466b00df9 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 8 Jul 2026 15:01:34 +0300 Subject: [PATCH 1/6] MS-1457 Add auto-capture toggle to face configuration --- .../infra/config/store/ConfigStoreModule.kt | 3 ++ ...ectConfigFaceAutocaptureConfigMigration.kt | 42 +++++++++++++++++++ .../migrations/models/OldProjectConfig.kt | 1 + .../store/local/models/FaceConfiguration.kt | 2 + .../config/store/models/FaceConfiguration.kt | 1 + .../remote/models/ApiFaceConfiguration.kt | 2 + .../src/main/proto/project_config.proto | 2 + .../ProjectConfigSharedPrefsMigrationTest.kt | 2 + .../store/models/FaceConfigurationTest.kt | 1 + .../infra/config/store/testtools/Models.kt | 3 ++ 10 files changed, 59 insertions(+) create mode 100644 infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutocaptureConfigMigration.kt diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigStoreModule.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigStoreModule.kt index f250857c63..cff4da0c1d 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigStoreModule.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigStoreModule.kt @@ -8,6 +8,7 @@ import com.simprints.infra.config.store.local.ConfigLocalDataSource import com.simprints.infra.config.store.local.ConfigLocalDataSourceImpl import com.simprints.infra.config.store.local.migrations.DeviceConfigSharedPrefsMigration import com.simprints.infra.config.store.local.migrations.ProjectConfigDevicesScopeBatchMigration +import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceAutocaptureConfigMigration import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceBioSdkMigration import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceEmptyVersionMigration import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceSdkQualityThresholdMigration @@ -82,6 +83,7 @@ object DataStoreModule { projectConfigSampleUploadMigration: ProjectConfigSampleUploadMigration, projectConfigDownSyncConfigMigration: ProjectConfigSimprintsSyncConfigMigration, projectConfigDevicesScopeBatchMigration: ProjectConfigDevicesScopeBatchMigration, + projectConfigFaceAutoCaptureConfigMigration: ProjectConfigFaceAutocaptureConfigMigration, ): DataStore = DataStoreFactory.create( serializer = ProjectConfigurationSerializer, produceFile = { appContext.dataStoreFile(PROJECT_CONFIG_DATA_STORE_FILE_NAME) }, @@ -97,6 +99,7 @@ object DataStoreModule { projectConfigSampleUploadMigration, projectConfigDownSyncConfigMigration, projectConfigDevicesScopeBatchMigration, + projectConfigFaceAutoCaptureConfigMigration, ), ) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutocaptureConfigMigration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutocaptureConfigMigration.kt new file mode 100644 index 0000000000..2b5a631eef --- /dev/null +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutocaptureConfigMigration.kt @@ -0,0 +1,42 @@ +package com.simprints.infra.config.store.local.migrations + +import androidx.datastore.core.DataMigration +import com.simprints.infra.config.store.local.models.ProtoProjectConfiguration +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MIGRATION +import com.simprints.infra.logging.Simber +import org.json.JSONException +import org.json.JSONObject +import javax.inject.Inject + +/** + * Can be removed once all the devices have been updated to 2026.3.0 + */ +class ProjectConfigFaceAutocaptureConfigMigration @Inject constructor() : DataMigration { + override suspend fun cleanUp() { + Simber.i("Migration of face auto-capture flag is complete", tag = MIGRATION) + } + + override suspend fun shouldMigrate(currentData: ProtoProjectConfiguration) = with(currentData) { + hasFace() && !face.hasIsAutocapture() && hasCustomJson() && customJson.contains("faceAutoCaptureEnabled") + } + + override suspend fun migrate(currentData: ProtoProjectConfiguration): ProtoProjectConfiguration { + Simber.i("Start migration of face auto-capture flag", tag = MIGRATION) + + val isAutocapture = try { + JSONObject(currentData.customJson).optBoolean("faceAutoCaptureEnabled") + } catch (e: JSONException) { + Simber.e("Failed to parse custom config", e, tag = MIGRATION) + false + } + + return currentData + .toBuilder() + .setFace( + currentData.face + .toBuilder() + .setIsAutocapture(isAutocapture) + .build(), + ).build() + } +} diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt index ed001c01dd..b11d192b62 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt @@ -106,6 +106,7 @@ internal data class OldProjectConfig( } else { FaceConfiguration( allowedSDKs = listOf(ModalitySdkType.RANK_ONE), + isAutocapture = false, rankOne = FaceConfiguration.FaceSdkConfiguration( nbOfImagesToCapture = faceNbOfFramesCaptured?.toIntOrNull() ?: DEFAULT_FACE_FRAMES_TO_CAPTURE, qualityThreshold = faceQualityThreshold.toFloat(), diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/FaceConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/FaceConfiguration.kt index 8b935ccd82..48a4f83f41 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/FaceConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/FaceConfiguration.kt @@ -8,6 +8,7 @@ import com.simprints.infra.config.store.models.ModalitySdkType internal fun FaceConfiguration.toProto(): ProtoFaceConfiguration = ProtoFaceConfiguration .newBuilder() .addAllAllowedSdks(allowedSDKs.map { it.toProtoFaceConfiguration() }) + .setIsAutocapture(isAutocapture) .also { if (rankOne != null) it.rankOne = rankOne.toProto() } .also { if (simFace != null) it.simFace = simFace.toProto() } .build() @@ -32,6 +33,7 @@ internal fun FaceConfiguration.ImageSavingStrategy.toProto(): ProtoFaceConfigura internal fun ProtoFaceConfiguration.toDomain(): FaceConfiguration = FaceConfiguration( allowedSDKs = allowedSdksList.map { it.toDomain() }, + isAutocapture = isAutocapture, rankOne = if (hasRankOne()) rankOne.toDomain() else null, simFace = if (hasSimFace()) simFace.toDomain() else null, ) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt index 26c811c2c6..c301d740dd 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt @@ -4,6 +4,7 @@ import com.simprints.core.domain.common.AgeGroup data class FaceConfiguration( val allowedSDKs: List, + val isAutocapture: Boolean, val rankOne: FaceSdkConfiguration?, val simFace: FaceSdkConfiguration?, ) { diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiFaceConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiFaceConfiguration.kt index f6b2216ede..16d9e0d682 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiFaceConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiFaceConfiguration.kt @@ -10,11 +10,13 @@ import kotlinx.serialization.Serializable @Serializable internal data class ApiFaceConfiguration( val allowedSDKs: List, + val isAutocapture: Boolean = false, val rankOne: ApiFaceSdkConfiguration? = null, val simFace: ApiFaceSdkConfiguration? = null, ) { fun toDomain(): FaceConfiguration = FaceConfiguration( allowedSDKs = allowedSDKs.map { it.toDomain() }, + isAutocapture = isAutocapture, rankOne = rankOne?.toDomain(), simFace = simFace?.toDomain(), ) diff --git a/infra/config-store/src/main/proto/project_config.proto b/infra/config-store/src/main/proto/project_config.proto index cf018c7198..ec514dddf1 100644 --- a/infra/config-store/src/main/proto/project_config.proto +++ b/infra/config-store/src/main/proto/project_config.proto @@ -68,6 +68,8 @@ message ProtoFaceConfiguration { optional ProtoFaceSdkConfiguration rank_one = 6; optional ProtoFaceSdkConfiguration sim_face = 7; + optional bool isAutocapture = 8; + enum ProtoBioSdk { RANK_ONE = 0; SIM_FACE = 1; diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt index 2aca1f32ec..aaf273de49 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt @@ -733,6 +733,7 @@ class ProjectConfigSharedPrefsMigrationTest { private val PROTO_FACE_CONFIGURATION = ProtoFaceConfiguration .newBuilder() + .setIsAutocapture(false) .addAllowedSdks(ProtoFaceConfiguration.ProtoBioSdk.RANK_ONE) .setRankOne( ProtoFaceConfiguration.ProtoFaceSdkConfiguration @@ -754,6 +755,7 @@ class ProjectConfigSharedPrefsMigrationTest { private val PROTO_FACE_DEFAULT_CONFIGURATION = ProtoFaceConfiguration .newBuilder() + .setIsAutocapture(false) .addAllowedSdks(ProtoFaceConfiguration.ProtoBioSdk.RANK_ONE) .setRankOne( ProtoFaceConfiguration.ProtoFaceSdkConfiguration diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/FaceConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/FaceConfigurationTest.kt index b6e9f23bad..4159cd7cd3 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/FaceConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/FaceConfigurationTest.kt @@ -22,6 +22,7 @@ class FaceConfigurationTest { private fun createConfiguration(): FaceConfiguration = FaceConfiguration( allowedSDKs = listOf(ModalitySdkType.RANK_ONE), + isAutocapture = true, rankOne = FaceConfiguration.FaceSdkConfiguration( nbOfImagesToCapture = 2, qualityThreshold = 0.5f, diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt index 8b3b3b9feb..2d4a8d1f72 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt @@ -172,6 +172,7 @@ internal val faceSdkConfiguration = FaceSdkConfiguration( internal val apiFaceConfiguration = ApiFaceConfiguration( allowedSDKs = listOf(ApiFaceConfiguration.BioSdk.RANK_ONE), + isAutocapture = true, rankOne = ApiFaceSdkConfiguration( nbOfImagesToCapture = 2, qualityThreshold = -1f, @@ -193,11 +194,13 @@ internal val apiFaceConfiguration = ApiFaceConfiguration( ) internal val faceConfiguration = FaceConfiguration( allowedSDKs = listOf(ModalitySdkType.RANK_ONE), + isAutocapture = true, rankOne = faceSdkConfiguration, simFace = faceSdkConfiguration, ) internal val protoFaceConfiguration = ProtoFaceConfiguration .newBuilder() + .setIsAutocapture(true) .addAllowedSdks(ProtoFaceConfiguration.ProtoBioSdk.RANK_ONE) .setRankOne( ProtoFaceConfiguration.ProtoFaceSdkConfiguration From 6752b90572c49b2fa3791b4ee9eafd6cbd77f01d Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 8 Jul 2026 15:21:59 +0300 Subject: [PATCH 2/6] MS-1457 Replace the usage of experimental auto-capture flag with face config --- .../LiveFeedbackFragmentViewModel.kt | 11 ++--- .../usecases/IsUsingAutoCaptureUseCase.kt | 5 +- ...eedbackAutoCaptureFragmentViewModelTest.kt | 4 +- .../usecases/IsUsingAutoCaptureUseCaseTest.kt | 4 +- .../dashboard/settings/SettingsFragment.kt | 4 +- .../dashboard/settings/SettingsViewModel.kt | 19 ++++---- .../settings/SettingsViewModelTest.kt | 47 ++++++++----------- 7 files changed, 39 insertions(+), 55 deletions(-) diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt index 9406981b72..96f4dd2d1f 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt @@ -15,9 +15,7 @@ import com.simprints.face.infra.basebiosdk.detection.Face import com.simprints.face.infra.basebiosdk.detection.FaceDetector import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT import com.simprints.infra.config.store.models.ModalitySdkType -import com.simprints.infra.config.store.models.experimental import com.simprints.infra.logging.LoggingConstants.CrashReportTag.FACE_CAPTURE import com.simprints.infra.logging.Simber import dagger.hilt.android.lifecycle.HiltViewModel @@ -29,6 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel internal class LiveFeedbackFragmentViewModel @Inject constructor( @@ -61,7 +60,6 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( private var captureImagingStartTime: Long = 0 private var isAutoCaptureHeldOff = true private var autoCaptureImagingTimeoutJob: Job? = null - private var autoCaptureImagingDurationMillis: Long = FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT private lateinit var faceDetector: FaceDetector suspend fun initAutoCapture() { @@ -82,7 +80,6 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( val config = configRepository.getProjectConfiguration() qualityThreshold = config.face?.getSdkConfiguration(bioSdk)?.qualityThreshold ?: 0f - autoCaptureImagingDurationMillis = config.experimental().faceAutoCaptureImagingDurationMillis } } @@ -124,7 +121,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( capturingState.postValue(CapturingState.CAPTURING) captureImagingStartTime = captureStartTime.ms autoCaptureImagingTimeoutJob = viewModelScope.launch { - delay(autoCaptureImagingDurationMillis) + delay(FACE_AUTO_CAPTURE_IMAGING_DURATION_MS.milliseconds) finishCapture(attemptNumber) } } @@ -154,7 +151,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } fun getNormalizedProgress(): Float = if (isAutoCapture) { - ((timeHelper.now().ms - captureImagingStartTime).toFloat() / autoCaptureImagingDurationMillis).coerceIn(0f, 1f) + ((timeHelper.now().ms - captureImagingStartTime).toFloat() / FACE_AUTO_CAPTURE_IMAGING_DURATION_MS).coerceIn(0f, 1f) } else { userCaptures.size.toFloat() / samplesToCapture } @@ -293,5 +290,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( companion object { private const val VALID_ROLL_DELTA = 15f private const val VALID_YAW_DELTA = 30f + + private const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MS = 3000L } } diff --git a/face/capture/src/main/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCase.kt b/face/capture/src/main/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCase.kt index 367a717ac0..0ec8e046c0 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCase.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCase.kt @@ -3,19 +3,18 @@ package com.simprints.face.capture.usecases import android.content.Context import androidx.preference.PreferenceManager import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.models.experimental import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @Singleton class IsUsingAutoCaptureUseCase @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, ) { private val preference = PreferenceManager.getDefaultSharedPreferences(context) operator fun invoke(projectConfiguration: ProjectConfiguration): Boolean { - val isFeatureEnabled = projectConfiguration.experimental().faceAutoCaptureEnabled + val isFeatureEnabled = projectConfiguration.face?.isAutocapture ?: false return isFeatureEnabled && isOptionTurnedOnInSettings() } diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackAutoCaptureFragmentViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackAutoCaptureFragmentViewModelTest.kt index 3e1cf9c9d8..0700ec2654 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackAutoCaptureFragmentViewModelTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackAutoCaptureFragmentViewModelTest.kt @@ -73,9 +73,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { ?.qualityThreshold } returns QUALITY_THRESHOLD every { isUsingAutoCapture.invoke(any()) } returns true - coEvery { - configRepository.getProjectConfiguration().custom - } returns mapOf("singleQualityFallbackRequired" to JsonPrimitive(false)) + every { timeHelper.now() } returnsMany (0..100L).map { Timestamp(it) } justRun { previewFrame.recycle() } val resolveFaceBioSdkUseCase = mockk { diff --git a/face/capture/src/test/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCaseTest.kt b/face/capture/src/test/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCaseTest.kt index 5b2338da16..1853231c50 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCaseTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCaseTest.kt @@ -7,7 +7,6 @@ import com.simprints.infra.config.store.models.ProjectConfiguration import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonPrimitive import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -38,8 +37,7 @@ class IsUsingAutoCaptureUseCaseTest { featureEnabled: Boolean, preferenceEnabled: Boolean, ) { - coEvery { projectConfiguration.custom } returns - mapOf("faceAutoCaptureEnabled" to JsonPrimitive(featureEnabled)) + coEvery { projectConfiguration.face?.isAutocapture } returns featureEnabled every { sharedPreferences.getBoolean("preference_enable_face_auto_capture", true) } returns preferenceEnabled } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsFragment.kt index b5ef297696..5a679ca423 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsFragment.kt @@ -62,8 +62,8 @@ internal class SettingsFragment : PreferenceFragmentCompat() { viewModel.generalConfiguration.observe(viewLifecycleOwner) { enableFingerprintSettings(it.modalities.contains(Modality.FINGERPRINT)) } - viewModel.experimentalConfiguration.observe(viewLifecycleOwner) { - showFaceAutoCaptureSetting(isVisible = it.faceAutoCaptureEnabled) + viewModel.faceConfiguration.observe(viewLifecycleOwner) { + showFaceAutoCaptureSetting(it?.isAutoCapture == true) } viewModel.languagePreference.observe(viewLifecycleOwner) { loadSelectedLanguage(it) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsViewModel.kt index b4f70568da..da75feb8c9 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsViewModel.kt @@ -3,23 +3,20 @@ package com.simprints.feature.dashboard.settings import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.simprints.core.livedata.LiveDataEvent import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.ConfigSyncCache +import com.simprints.infra.config.store.models.FaceConfiguration import com.simprints.infra.config.store.models.GeneralConfiguration -import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.config.store.models.experimental import com.simprints.infra.events.device.DeviceEventTracker import com.simprints.infra.logging.LoggingConstants.CrashReportTag.SETTINGS import com.simprints.infra.logging.Simber import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -34,10 +31,9 @@ internal class SettingsViewModel @Inject constructor( get() = _generalConfiguration private val _generalConfiguration = MutableLiveData() - val experimentalConfiguration = configRepository - .observeProjectConfiguration() - .map(ProjectConfiguration::experimental) - .asLiveData(viewModelScope.coroutineContext) + val faceConfiguration: LiveData + get() = _faceConfiguration + private val _faceConfiguration = MutableLiveData() val languagePreference: LiveData get() = _languagePreference @@ -77,12 +73,13 @@ internal class SettingsViewModel @Inject constructor( } private fun load() = viewModelScope.launch { - val configuration = configRepository.getProjectConfiguration().general + val configuration = configRepository.getProjectConfiguration() _sinceConfigLastUpdated.send(configSyncCache.sinceLastUpdateTime()) _languagePreference.postValue(configRepository.getDeviceConfiguration().language) - _generalConfiguration.postValue(configuration) - _settingsLocked.postValue(configuration.settingsPassword) + _generalConfiguration.postValue(configuration.general) + _faceConfiguration.postValue(configuration.face) + _settingsLocked.postValue(configuration.general.settingsPassword) } fun unlockSettings() { diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/SettingsViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/SettingsViewModelTest.kt index 688b2868d1..da3bc3ca17 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/SettingsViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/SettingsViewModelTest.kt @@ -7,9 +7,8 @@ import com.simprints.core.domain.common.Modality import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.ConfigSyncCache import com.simprints.infra.config.store.models.DeviceConfiguration -import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration +import com.simprints.infra.config.store.models.FaceConfiguration import com.simprints.infra.config.store.models.GeneralConfiguration -import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.events.device.DeviceEventTracker import com.simprints.infra.sync.SyncOrchestrator @@ -18,7 +17,6 @@ import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonPrimitive import org.junit.Before import org.junit.Rule import org.junit.Test @@ -40,6 +38,13 @@ class SettingsViewModelTest { settingsPassword = SettingsPasswordConfig.Locked("1234"), ) + private val faceConfiguration = FaceConfiguration( + allowedSDKs = listOf(), + isAutoCapture = true, + rankOne = null, + simFace = null, + ) + @MockK private lateinit var configRepository: ConfigRepository @@ -59,6 +64,7 @@ class SettingsViewModelTest { MockKAnnotations.init(this, relaxed = true) coEvery { configRepository.getProjectConfiguration().general } returns generalConfiguration + coEvery { configRepository.getProjectConfiguration().face } returns faceConfiguration coEvery { configRepository.getDeviceConfiguration().language } returns LANGUAGE coEvery { configSyncCache.sinceLastUpdateTime() } returnsMany listOf( @@ -71,36 +77,23 @@ class SettingsViewModelTest { viewModel = SettingsViewModel(configRepository, syncOrchestrator, configSyncCache, deviceEventTracker) } - @Test - fun `experimentalConfiguration live data should follow the project experimental configuration`() = runTest { - val experimentalConfig1 = mapOf("key1" to JsonPrimitive("value1")) - val experimentalConfig2 = mapOf("key2" to JsonPrimitive("value2")) - - coEvery { configRepository.observeProjectConfiguration() } returns flowOf( - mockk(relaxed = true) { - every { custom } returns experimentalConfig1 - }, - mockk(relaxed = true) { - every { custom } returns experimentalConfig2 - }, - ) - viewModel = SettingsViewModel(configRepository, syncOrchestrator, configSyncCache, deviceEventTracker) - - assertThat(viewModel.experimentalConfiguration.test().valueHistory()) - .isEqualTo( - listOf( - ExperimentalProjectConfiguration(experimentalConfig1), - ExperimentalProjectConfiguration(experimentalConfig2), - ), - ) - } - @Test fun `should initialize the live data correctly`() { assertThat(viewModel.generalConfiguration.value).isEqualTo(generalConfiguration) assertThat(viewModel.languagePreference.value).isEqualTo(LANGUAGE) assertThat(viewModel.settingsLocked.value).isEqualTo(SettingsPasswordConfig.Locked("1234")) assertThat(viewModel.sinceConfigLastUpdated.value?.peekContent()).isEqualTo(LAST_UPDATED) + assertThat(viewModel.faceConfiguration.value).isEqualTo(faceConfiguration) + } + + @Test + fun `should initialize the live data correctly when no face configured`() { + coEvery { configRepository.getProjectConfiguration().face } returns null + + // Recreating to set the new value + viewModel = SettingsViewModel(configRepository, syncOrchestrator, configSyncCache, deviceEventTracker) + + assertThat(viewModel.faceConfiguration.value).isNull() } @Test From 2cb5d51c48aa678e97061d99f2081a6d9384301b Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 8 Jul 2026 15:22:26 +0300 Subject: [PATCH 3/6] MS-1457 Remove experimental auto-capture flag --- .../ExperimentalProjectConfiguration.kt | 20 ---------- .../ExperimentalProjectConfigurationTest.kt | 39 ------------------- 2 files changed, 59 deletions(-) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt index 3185844603..01284a76b4 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt @@ -21,21 +21,6 @@ data class ExperimentalProjectConfiguration( ?.booleanOrNull .let { it == true } - val faceAutoCaptureEnabled: Boolean - get() = customConfig - ?.get(FACE_AUTO_CAPTURE_ENABLED) - ?.jsonPrimitive - ?.booleanOrNull - .let { it == true } - - val faceAutoCaptureImagingDurationMillis: Long - get() = customConfig - ?.get(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS) - ?.jsonPrimitive - ?.longOrNull - ?.coerceIn(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN, FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX) - ?: FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT - val recordsDbMigrationFromRealmEnabled: Boolean get() = customConfig ?.get(RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_ENABLED) @@ -182,15 +167,10 @@ data class ExperimentalProjectConfiguration( companion object { internal const val DISABLE_SUBJECT_POOL_VALIDATION = "disableSubjectPoolValidation" - internal const val FACE_AUTO_CAPTURE_ENABLED = "faceAutoCaptureEnabled" - internal const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS = "faceAutoCaptureImagingDurationMillis" internal const val RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_ENABLED = "recordsDbMigrationFromRealmEnabled" const val RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_MAX_RETRIES = "recordsDbMigrationFromRealmMaxRetries" internal const val RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_DEFAULT_MAX_RETRIES = 10 - internal const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN = 1L - const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT = 3_000L - internal const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX = 60_000L internal const val FALLBACK_TO_COMMCARE_THRESHOLD_DAYS = "fallbackToCommCareThresholdDays" internal const val FALLBACK_TO_COMMCARE_THRESHOLD_DAYS_DEFAULT = 5L diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt index 12038c119c..2e7b2ad1ae 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt @@ -3,11 +3,6 @@ package com.simprints.infra.config.store.models import com.google.common.truth.Truth.* import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.ALLOW_CONFIRMING_GUIDS_NOT_IN_CALLBACK import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.DISABLE_SUBJECT_POOL_VALIDATION -import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_ENABLED -import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS -import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT -import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX -import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FALLBACK_TO_COMMCARE_THRESHOLD_DAYS import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FALLBACK_TO_COMMCARE_THRESHOLD_DAYS_DEFAULT import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.MFID_LIGHTING_CONDITIONS_ASSESSMENT_ENABLED @@ -59,40 +54,6 @@ internal class ExperimentalProjectConfigurationTest { } } - @Test - fun `check face auto capture flag correctly`() { - mapOf( - // Value not present - emptyMap() to false, - // Value not boolean - mapOf(FACE_AUTO_CAPTURE_ENABLED to JsonPrimitive(1)) to false, - // Value present and FALSE - mapOf(FACE_AUTO_CAPTURE_ENABLED to JsonPrimitive(false)) to false, - // Value present and TRUE - mapOf(FACE_AUTO_CAPTURE_ENABLED to JsonPrimitive(true)) to true, - ).forEach { (config, result) -> - assertThat(ExperimentalProjectConfiguration(config).faceAutoCaptureEnabled).isEqualTo(result) - } - } - - @Test - fun `check face auto capture imaging duration flag correctly`() { - mapOf( - // Value not present - emptyMap() to FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT, - // Value not int - mapOf(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS to JsonPrimitive(true)) to FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT, - // Value present and lesser than min - mapOf(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS to JsonPrimitive(0)) to FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN, - // Value present and greater than max - mapOf(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS to JsonPrimitive(60_001)) to FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX, - // Value present and within the range - mapOf(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS to JsonPrimitive(1_000)) to 1_000L, - ).forEach { (config, result) -> - assertThat(ExperimentalProjectConfiguration(config).faceAutoCaptureImagingDurationMillis).isEqualTo(result) - } - } - @Test fun `check records DB migration flag correctly`() { mapOf( From 45b876d1ac50859021a80d5c2380c27be224d66f Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 13 Jul 2026 10:50:10 +0300 Subject: [PATCH 4/6] MS-1457 Update capitalisation in AutoCapture --- .../face/capture/usecases/IsUsingAutoCaptureUseCase.kt | 2 +- .../capture/usecases/IsUsingAutoCaptureUseCaseTest.kt | 2 +- .../com/simprints/infra/config/store/ConfigStoreModule.kt | 4 ++-- ....kt => ProjectConfigFaceAutoCaptureConfigMigration.kt} | 8 ++++---- .../store/local/migrations/models/OldProjectConfig.kt | 2 +- .../infra/config/store/local/models/FaceConfiguration.kt | 4 ++-- .../infra/config/store/models/FaceConfiguration.kt | 2 +- .../config/store/remote/models/ApiFaceConfiguration.kt | 4 ++-- infra/config-store/src/main/proto/project_config.proto | 2 +- .../migrations/ProjectConfigSharedPrefsMigrationTest.kt | 4 ++-- .../infra/config/store/models/FaceConfigurationTest.kt | 2 +- .../com/simprints/infra/config/store/testtools/Models.kt | 6 +++--- .../com/simprints/infra/sync/config/testtools/Models.kt | 1 + 13 files changed, 22 insertions(+), 21 deletions(-) rename infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/{ProjectConfigFaceAutocaptureConfigMigration.kt => ProjectConfigFaceAutoCaptureConfigMigration.kt} (86%) diff --git a/face/capture/src/main/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCase.kt b/face/capture/src/main/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCase.kt index 0ec8e046c0..1d8df3b4c9 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCase.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCase.kt @@ -14,7 +14,7 @@ class IsUsingAutoCaptureUseCase @Inject constructor( private val preference = PreferenceManager.getDefaultSharedPreferences(context) operator fun invoke(projectConfiguration: ProjectConfiguration): Boolean { - val isFeatureEnabled = projectConfiguration.face?.isAutocapture ?: false + val isFeatureEnabled = projectConfiguration.face?.isAutoCapture ?: false return isFeatureEnabled && isOptionTurnedOnInSettings() } diff --git a/face/capture/src/test/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCaseTest.kt b/face/capture/src/test/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCaseTest.kt index 1853231c50..a14f46b399 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCaseTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/usecases/IsUsingAutoCaptureUseCaseTest.kt @@ -37,7 +37,7 @@ class IsUsingAutoCaptureUseCaseTest { featureEnabled: Boolean, preferenceEnabled: Boolean, ) { - coEvery { projectConfiguration.face?.isAutocapture } returns featureEnabled + every { projectConfiguration.face?.isAutoCapture } returns featureEnabled every { sharedPreferences.getBoolean("preference_enable_face_auto_capture", true) } returns preferenceEnabled } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigStoreModule.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigStoreModule.kt index cff4da0c1d..02911861b4 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigStoreModule.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigStoreModule.kt @@ -8,7 +8,7 @@ import com.simprints.infra.config.store.local.ConfigLocalDataSource import com.simprints.infra.config.store.local.ConfigLocalDataSourceImpl import com.simprints.infra.config.store.local.migrations.DeviceConfigSharedPrefsMigration import com.simprints.infra.config.store.local.migrations.ProjectConfigDevicesScopeBatchMigration -import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceAutocaptureConfigMigration +import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceAutoCaptureConfigMigration import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceBioSdkMigration import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceEmptyVersionMigration import com.simprints.infra.config.store.local.migrations.ProjectConfigFaceSdkQualityThresholdMigration @@ -83,7 +83,7 @@ object DataStoreModule { projectConfigSampleUploadMigration: ProjectConfigSampleUploadMigration, projectConfigDownSyncConfigMigration: ProjectConfigSimprintsSyncConfigMigration, projectConfigDevicesScopeBatchMigration: ProjectConfigDevicesScopeBatchMigration, - projectConfigFaceAutoCaptureConfigMigration: ProjectConfigFaceAutocaptureConfigMigration, + projectConfigFaceAutoCaptureConfigMigration: ProjectConfigFaceAutoCaptureConfigMigration, ): DataStore = DataStoreFactory.create( serializer = ProjectConfigurationSerializer, produceFile = { appContext.dataStoreFile(PROJECT_CONFIG_DATA_STORE_FILE_NAME) }, diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutocaptureConfigMigration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigration.kt similarity index 86% rename from infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutocaptureConfigMigration.kt rename to infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigration.kt index 2b5a631eef..e206e04e81 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutocaptureConfigMigration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigration.kt @@ -11,19 +11,19 @@ import javax.inject.Inject /** * Can be removed once all the devices have been updated to 2026.3.0 */ -class ProjectConfigFaceAutocaptureConfigMigration @Inject constructor() : DataMigration { +class ProjectConfigFaceAutoCaptureConfigMigration @Inject constructor() : DataMigration { override suspend fun cleanUp() { Simber.i("Migration of face auto-capture flag is complete", tag = MIGRATION) } override suspend fun shouldMigrate(currentData: ProtoProjectConfiguration) = with(currentData) { - hasFace() && !face.hasIsAutocapture() && hasCustomJson() && customJson.contains("faceAutoCaptureEnabled") + hasFace() && !face.hasIsAutoCapture() && hasCustomJson() && customJson.contains("faceAutoCaptureEnabled") } override suspend fun migrate(currentData: ProtoProjectConfiguration): ProtoProjectConfiguration { Simber.i("Start migration of face auto-capture flag", tag = MIGRATION) - val isAutocapture = try { + val isAutoCapture = try { JSONObject(currentData.customJson).optBoolean("faceAutoCaptureEnabled") } catch (e: JSONException) { Simber.e("Failed to parse custom config", e, tag = MIGRATION) @@ -35,7 +35,7 @@ class ProjectConfigFaceAutocaptureConfigMigration @Inject constructor() : DataMi .setFace( currentData.face .toBuilder() - .setIsAutocapture(isAutocapture) + .setIsAutoCapture(isAutoCapture) .build(), ).build() } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt index b11d192b62..f1e001a39e 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt @@ -106,7 +106,7 @@ internal data class OldProjectConfig( } else { FaceConfiguration( allowedSDKs = listOf(ModalitySdkType.RANK_ONE), - isAutocapture = false, + isAutoCapture = false, rankOne = FaceConfiguration.FaceSdkConfiguration( nbOfImagesToCapture = faceNbOfFramesCaptured?.toIntOrNull() ?: DEFAULT_FACE_FRAMES_TO_CAPTURE, qualityThreshold = faceQualityThreshold.toFloat(), diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/FaceConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/FaceConfiguration.kt index 48a4f83f41..0256be24a2 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/FaceConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/FaceConfiguration.kt @@ -8,7 +8,7 @@ import com.simprints.infra.config.store.models.ModalitySdkType internal fun FaceConfiguration.toProto(): ProtoFaceConfiguration = ProtoFaceConfiguration .newBuilder() .addAllAllowedSdks(allowedSDKs.map { it.toProtoFaceConfiguration() }) - .setIsAutocapture(isAutocapture) + .setIsAutoCapture(isAutoCapture) .also { if (rankOne != null) it.rankOne = rankOne.toProto() } .also { if (simFace != null) it.simFace = simFace.toProto() } .build() @@ -33,7 +33,7 @@ internal fun FaceConfiguration.ImageSavingStrategy.toProto(): ProtoFaceConfigura internal fun ProtoFaceConfiguration.toDomain(): FaceConfiguration = FaceConfiguration( allowedSDKs = allowedSdksList.map { it.toDomain() }, - isAutocapture = isAutocapture, + isAutoCapture = isAutoCapture, rankOne = if (hasRankOne()) rankOne.toDomain() else null, simFace = if (hasSimFace()) simFace.toDomain() else null, ) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt index c301d740dd..ed447713c7 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt @@ -4,7 +4,7 @@ import com.simprints.core.domain.common.AgeGroup data class FaceConfiguration( val allowedSDKs: List, - val isAutocapture: Boolean, + val isAutoCapture: Boolean, val rankOne: FaceSdkConfiguration?, val simFace: FaceSdkConfiguration?, ) { diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiFaceConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiFaceConfiguration.kt index 16d9e0d682..5aa931d023 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiFaceConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiFaceConfiguration.kt @@ -10,13 +10,13 @@ import kotlinx.serialization.Serializable @Serializable internal data class ApiFaceConfiguration( val allowedSDKs: List, - val isAutocapture: Boolean = false, + val isAutoCapture: Boolean = false, val rankOne: ApiFaceSdkConfiguration? = null, val simFace: ApiFaceSdkConfiguration? = null, ) { fun toDomain(): FaceConfiguration = FaceConfiguration( allowedSDKs = allowedSDKs.map { it.toDomain() }, - isAutocapture = isAutocapture, + isAutoCapture = isAutoCapture, rankOne = rankOne?.toDomain(), simFace = simFace?.toDomain(), ) diff --git a/infra/config-store/src/main/proto/project_config.proto b/infra/config-store/src/main/proto/project_config.proto index ec514dddf1..d17ba87eeb 100644 --- a/infra/config-store/src/main/proto/project_config.proto +++ b/infra/config-store/src/main/proto/project_config.proto @@ -68,7 +68,7 @@ message ProtoFaceConfiguration { optional ProtoFaceSdkConfiguration rank_one = 6; optional ProtoFaceSdkConfiguration sim_face = 7; - optional bool isAutocapture = 8; + optional bool is_auto_capture = 8; enum ProtoBioSdk { RANK_ONE = 0; diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt index aaf273de49..8a0394b5c6 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt @@ -733,7 +733,7 @@ class ProjectConfigSharedPrefsMigrationTest { private val PROTO_FACE_CONFIGURATION = ProtoFaceConfiguration .newBuilder() - .setIsAutocapture(false) + .setIsAutoCapture(false) .addAllowedSdks(ProtoFaceConfiguration.ProtoBioSdk.RANK_ONE) .setRankOne( ProtoFaceConfiguration.ProtoFaceSdkConfiguration @@ -755,7 +755,7 @@ class ProjectConfigSharedPrefsMigrationTest { private val PROTO_FACE_DEFAULT_CONFIGURATION = ProtoFaceConfiguration .newBuilder() - .setIsAutocapture(false) + .setIsAutoCapture(false) .addAllowedSdks(ProtoFaceConfiguration.ProtoBioSdk.RANK_ONE) .setRankOne( ProtoFaceConfiguration.ProtoFaceSdkConfiguration diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/FaceConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/FaceConfigurationTest.kt index 4159cd7cd3..e21cd49f0d 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/FaceConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/FaceConfigurationTest.kt @@ -22,7 +22,7 @@ class FaceConfigurationTest { private fun createConfiguration(): FaceConfiguration = FaceConfiguration( allowedSDKs = listOf(ModalitySdkType.RANK_ONE), - isAutocapture = true, + isAutoCapture = true, rankOne = FaceConfiguration.FaceSdkConfiguration( nbOfImagesToCapture = 2, qualityThreshold = 0.5f, diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt index 2d4a8d1f72..0512c3634d 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt @@ -172,7 +172,7 @@ internal val faceSdkConfiguration = FaceSdkConfiguration( internal val apiFaceConfiguration = ApiFaceConfiguration( allowedSDKs = listOf(ApiFaceConfiguration.BioSdk.RANK_ONE), - isAutocapture = true, + isAutoCapture = true, rankOne = ApiFaceSdkConfiguration( nbOfImagesToCapture = 2, qualityThreshold = -1f, @@ -194,13 +194,13 @@ internal val apiFaceConfiguration = ApiFaceConfiguration( ) internal val faceConfiguration = FaceConfiguration( allowedSDKs = listOf(ModalitySdkType.RANK_ONE), - isAutocapture = true, + isAutoCapture = true, rankOne = faceSdkConfiguration, simFace = faceSdkConfiguration, ) internal val protoFaceConfiguration = ProtoFaceConfiguration .newBuilder() - .setIsAutocapture(true) + .setIsAutoCapture(true) .addAllowedSdks(ProtoFaceConfiguration.ProtoBioSdk.RANK_ONE) .setRankOne( ProtoFaceConfiguration.ProtoFaceSdkConfiguration diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt index d517b76ce0..620a009391 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt @@ -55,6 +55,7 @@ internal val faceConfiguration = decisionPolicy = decisionPolicy, version = "1.0", ), + isAutoCapture = false, simFace = FaceConfiguration.FaceSdkConfiguration( nbOfImagesToCapture = 2, qualityThreshold = -1f, From 25dfdc0dcb262fcc15fde8ca9fec045b9834f3b9 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 13 Jul 2026 12:26:18 +0300 Subject: [PATCH 5/6] MS-1457 Change JSON parsing method in config migration and add tests --- ...ectConfigFaceAutoCaptureConfigMigration.kt | 15 ++- ...onfigFaceAutoCaptureConfigMigrationTest.kt | 116 ++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigrationTest.kt diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigration.kt index e206e04e81..acbdb8dffa 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigration.kt @@ -4,8 +4,11 @@ import androidx.datastore.core.DataMigration import com.simprints.infra.config.store.local.models.ProtoProjectConfiguration import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MIGRATION import com.simprints.infra.logging.Simber -import org.json.JSONException -import org.json.JSONObject +import com.simprints.infra.serialization.SimJson +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import javax.inject.Inject /** @@ -24,8 +27,12 @@ class ProjectConfigFaceAutoCaptureConfigMigration @Inject constructor() : DataMi Simber.i("Start migration of face auto-capture flag", tag = MIGRATION) val isAutoCapture = try { - JSONObject(currentData.customJson).optBoolean("faceAutoCaptureEnabled") - } catch (e: JSONException) { + SimJson + .parseToJsonElement(currentData.customJson) + .jsonObject["faceAutoCaptureEnabled"] + ?.jsonPrimitive + ?.booleanOrNull ?: false + } catch (e: SerializationException) { Simber.e("Failed to parse custom config", e, tag = MIGRATION) false } diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigrationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigrationTest.kt new file mode 100644 index 0000000000..222a22fee6 --- /dev/null +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigFaceAutoCaptureConfigMigrationTest.kt @@ -0,0 +1,116 @@ +package com.simprints.infra.config.store.local.migrations + +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.config.store.local.models.ProtoFaceConfiguration +import com.simprints.infra.config.store.local.models.ProtoProjectConfiguration +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ProjectConfigFaceAutoCaptureConfigMigrationTest { + private val migration = ProjectConfigFaceAutoCaptureConfigMigration() + + @Test + fun `should not migrate if face is missing`() = runTest { + val currentData = ProtoProjectConfiguration.newBuilder().build() + + assertThat(migration.shouldMigrate(currentData)).isFalse() + } + + @Test + fun `should not migrate if face already has auto capture`() = runTest { + val currentData = projectConfig( + customJson = """{"faceAutoCaptureEnabled":true}""", + hasAutoCapture = true, + isAutoCapture = true, + ) + + assertThat(migration.shouldMigrate(currentData)).isFalse() + } + + @Test + fun `should not migrate if face already has disabled auto capture`() = runTest { + val currentData = projectConfig( + customJson = """{"faceAutoCaptureEnabled":false}""", + hasAutoCapture = true, + isAutoCapture = false, + ) + + assertThat(migration.shouldMigrate(currentData)).isFalse() + } + + @Test + fun `should not migrate if custom json is missing`() = runTest { + val currentData = projectConfig() + + assertThat(migration.shouldMigrate(currentData)).isFalse() + } + + @Test + fun `should not migrate if custom json does not contain auto capture flag`() = runTest { + val currentData = projectConfig(customJson = """{"somethingElse":true}""") + + assertThat(migration.shouldMigrate(currentData)).isFalse() + } + + @Test + fun `should migrate if custom json contains auto capture flag`() = runTest { + val currentData = projectConfig(customJson = """{"faceAutoCaptureEnabled":true}""") + + assertThat(migration.shouldMigrate(currentData)).isTrue() + } + + @Test + fun `migrate should set auto capture to true when custom json flag is true`() = runTest { + val currentData = projectConfig(customJson = """{"faceAutoCaptureEnabled":true}""") + val migratedData = migration.migrate(currentData) + + assertThat(migratedData.face.isAutoCapture).isTrue() + } + + @Test + fun `migrate should set auto capture to false when custom json flag is false`() = runTest { + val currentData = projectConfig(customJson = """{"faceAutoCaptureEnabled":false}""") + + val migratedData = migration.migrate(currentData) + + assertThat(migratedData.face.isAutoCapture).isFalse() + } + + @Test + fun `migrate should default auto capture to false when custom json is malformed`() = runTest { + val currentData = projectConfig(customJson = """{"faceAutoCaptureEnabled":true""") + + val migratedData = migration.migrate(currentData) + + assertThat(migratedData.face.isAutoCapture).isFalse() + } + + private fun projectConfig( + customJson: String? = null, + hasAutoCapture: Boolean = false, + isAutoCapture: Boolean = false, + ): ProtoProjectConfiguration { + val faceBuilder = ProtoFaceConfiguration + .newBuilder() + .setRankOne( + ProtoFaceConfiguration.ProtoFaceSdkConfiguration + .newBuilder() + .setVersion("1.0") + .build(), + ) + + if (hasAutoCapture) { + faceBuilder.setIsAutoCapture(isAutoCapture) + } + + val projectBuilder = ProtoProjectConfiguration + .newBuilder() + .setFace(faceBuilder.build()) + + if (customJson != null) { + projectBuilder.setCustomJson(customJson) + } + + return projectBuilder.build() + } +} From 9923ba1833f94fb2c083ff11888bad6521a19169 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 14 Jul 2026 13:57:10 +0300 Subject: [PATCH 6/6] MS-1457 Restore custom auto capture duration configuration --- .../LiveFeedbackFragmentViewModel.kt | 11 +++++----- .../ExperimentalProjectConfiguration.kt | 12 ++++++++++ .../ExperimentalProjectConfigurationTest.kt | 22 +++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt index 96f4dd2d1f..9406981b72 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt @@ -15,7 +15,9 @@ import com.simprints.face.infra.basebiosdk.detection.Face import com.simprints.face.infra.basebiosdk.detection.FaceDetector import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT import com.simprints.infra.config.store.models.ModalitySdkType +import com.simprints.infra.config.store.models.experimental import com.simprints.infra.logging.LoggingConstants.CrashReportTag.FACE_CAPTURE import com.simprints.infra.logging.Simber import dagger.hilt.android.lifecycle.HiltViewModel @@ -27,7 +29,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds @HiltViewModel internal class LiveFeedbackFragmentViewModel @Inject constructor( @@ -60,6 +61,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( private var captureImagingStartTime: Long = 0 private var isAutoCaptureHeldOff = true private var autoCaptureImagingTimeoutJob: Job? = null + private var autoCaptureImagingDurationMillis: Long = FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT private lateinit var faceDetector: FaceDetector suspend fun initAutoCapture() { @@ -80,6 +82,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( val config = configRepository.getProjectConfiguration() qualityThreshold = config.face?.getSdkConfiguration(bioSdk)?.qualityThreshold ?: 0f + autoCaptureImagingDurationMillis = config.experimental().faceAutoCaptureImagingDurationMillis } } @@ -121,7 +124,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( capturingState.postValue(CapturingState.CAPTURING) captureImagingStartTime = captureStartTime.ms autoCaptureImagingTimeoutJob = viewModelScope.launch { - delay(FACE_AUTO_CAPTURE_IMAGING_DURATION_MS.milliseconds) + delay(autoCaptureImagingDurationMillis) finishCapture(attemptNumber) } } @@ -151,7 +154,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } fun getNormalizedProgress(): Float = if (isAutoCapture) { - ((timeHelper.now().ms - captureImagingStartTime).toFloat() / FACE_AUTO_CAPTURE_IMAGING_DURATION_MS).coerceIn(0f, 1f) + ((timeHelper.now().ms - captureImagingStartTime).toFloat() / autoCaptureImagingDurationMillis).coerceIn(0f, 1f) } else { userCaptures.size.toFloat() / samplesToCapture } @@ -290,7 +293,5 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( companion object { private const val VALID_ROLL_DELTA = 15f private const val VALID_YAW_DELTA = 30f - - private const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MS = 3000L } } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt index 01284a76b4..28147c0d8e 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt @@ -21,6 +21,14 @@ data class ExperimentalProjectConfiguration( ?.booleanOrNull .let { it == true } + val faceAutoCaptureImagingDurationMillis: Long + get() = customConfig + ?.get(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS) + ?.jsonPrimitive + ?.longOrNull + ?.coerceIn(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN, FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX) + ?: FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT + val recordsDbMigrationFromRealmEnabled: Boolean get() = customConfig ?.get(RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_ENABLED) @@ -167,10 +175,14 @@ data class ExperimentalProjectConfiguration( companion object { internal const val DISABLE_SUBJECT_POOL_VALIDATION = "disableSubjectPoolValidation" + internal const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS = "faceAutoCaptureImagingDurationMillis" internal const val RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_ENABLED = "recordsDbMigrationFromRealmEnabled" const val RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_MAX_RETRIES = "recordsDbMigrationFromRealmMaxRetries" internal const val RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_DEFAULT_MAX_RETRIES = 10 + internal const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN = 1L + const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT = 3_000L + internal const val FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX = 60_000L internal const val FALLBACK_TO_COMMCARE_THRESHOLD_DAYS = "fallbackToCommCareThresholdDays" internal const val FALLBACK_TO_COMMCARE_THRESHOLD_DAYS_DEFAULT = 5L diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt index 2e7b2ad1ae..c10d2c46e5 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt @@ -3,6 +3,10 @@ package com.simprints.infra.config.store.models import com.google.common.truth.Truth.* import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.ALLOW_CONFIRMING_GUIDS_NOT_IN_CALLBACK import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.DISABLE_SUBJECT_POOL_VALIDATION +import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS +import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT +import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX +import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FALLBACK_TO_COMMCARE_THRESHOLD_DAYS import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FALLBACK_TO_COMMCARE_THRESHOLD_DAYS_DEFAULT import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.MFID_LIGHTING_CONDITIONS_ASSESSMENT_ENABLED @@ -54,6 +58,24 @@ internal class ExperimentalProjectConfigurationTest { } } + @Test + fun `check face auto capture imaging duration flag correctly`() { + mapOf( + // Value not present + emptyMap() to FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT, + // Value not int + mapOf(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS to JsonPrimitive(true)) to FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT, + // Value present and lesser than min + mapOf(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS to JsonPrimitive(0)) to FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MIN, + // Value present and greater than max + mapOf(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS to JsonPrimitive(60_001)) to FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_MAX, + // Value present and within the range + mapOf(FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS to JsonPrimitive(1_000)) to 1_000L, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).faceAutoCaptureImagingDurationMillis).isEqualTo(result) + } + } + @Test fun `check records DB migration flag correctly`() { mapOf(