From f9cd67664154958b748f2fe6588488f6db91b9c2 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Sat, 9 May 2026 21:56:16 +0100 Subject: [PATCH 01/14] Update dev version name to 2026.2.0 --- build-logic/build_properties.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-logic/build_properties.gradle.kts b/build-logic/build_properties.gradle.kts index aa933598a7..af8a84e84d 100644 --- a/build-logic/build_properties.gradle.kts +++ b/build-logic/build_properties.gradle.kts @@ -20,7 +20,7 @@ extra.apply { * Dev version >= 2025.3.0 is required to receive smaples and structured down sync configuration * Dev version >= 2026.2.0 is required to track events in "Device" scope */ - set("VERSION_NAME", "2026.1.0") + set("VERSION_NAME", "2026.2.0") /** * Build type. The version code describes which build type was used for the build. From 084e94223c60d2913642631027271016d71bde51 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 18 May 2026 18:00:39 +0300 Subject: [PATCH 02/14] MS-1431 Replace blocking sync use case with async listening for the sync state --- .../screen/ValidateSubjectPoolFragment.kt | 2 +- .../screen/ValidateSubjectPoolViewModel.kt | 32 ++++- .../usecase/RunBlockingEventSyncUseCase.kt | 30 ---- .../ValidateSubjectPoolViewModelTest.kt | 82 ++++++++--- .../RunBlockingEventSyncUseCaseTest.kt | 135 ------------------ 5 files changed, 91 insertions(+), 190 deletions(-) delete mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt delete mode 100644 feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt index 04810c2376..4fa239c6c3 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt @@ -39,7 +39,7 @@ internal class ValidateSubjectPoolFragment : Fragment(R.layout.fragment_validate binding.validationActionsClose.setOnClickListener { finishWithResult(false) } binding.validationActionsContinue.setOnClickListener { finishWithResult(true) } - binding.validationActionsSync.setOnClickListener { viewModel.syncAndRetry(params.enrolmentRecordQuery) } + binding.validationActionsSync.setOnClickListener { viewModel.startSync(params.enrolmentRecordQuery) } viewModel.checkIdentificationPool(params.enrolmentRecordQuery) } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt index 91c0b84292..1247b193cf 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt @@ -8,10 +8,13 @@ import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.feature.validatepool.usecase.HasRecordsUseCase import com.simprints.feature.validatepool.usecase.IsModuleIdNotSyncedUseCase -import com.simprints.feature.validatepool.usecase.RunBlockingEventSyncUseCase import com.simprints.feature.validatepool.usecase.ShouldSuggestSyncUseCase import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecordQuery +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,13 +23,31 @@ internal class ValidateSubjectPoolViewModel @Inject constructor( private val hasRecords: HasRecordsUseCase, private val isModuleIdNotSynced: IsModuleIdNotSyncedUseCase, private val shouldSuggestSync: ShouldSuggestSyncUseCase, - private val runBlockingSync: RunBlockingEventSyncUseCase, + private val syncOrchestrator: SyncOrchestrator, ) : ViewModel() { + private lateinit var cachedQuery: EnrolmentRecordQuery + val state: LiveData> get() = _state private var _state = MutableLiveData>() private var isSyncing: Boolean = false + private val syncStatusFlow = syncOrchestrator + .observeSyncState() + .filter { isSyncing } + .map { it.eventSyncState } + + init { + viewModelScope.launch { + syncStatusFlow.collect { syncState -> + if (syncState.isSyncReporterCompleted()) { + isSyncing = false + checkIdentificationPool(cachedQuery) + } + } + } + } + fun checkIdentificationPool(enrolmentRecordQuery: EnrolmentRecordQuery) = viewModelScope.launch { if (isSyncing) { return@launch @@ -44,11 +65,10 @@ internal class ValidateSubjectPoolViewModel @Inject constructor( _state.send(validationState) } - fun syncAndRetry(enrolmentRecordQuery: EnrolmentRecordQuery) = viewModelScope.launch { + fun startSync(enrolmentRecordQuery: EnrolmentRecordQuery) = viewModelScope.launch { + cachedQuery = enrolmentRecordQuery _state.send(ValidateSubjectPoolState.SyncInProgress) isSyncing = true - runBlockingSync() - isSyncing = false - checkIdentificationPool(enrolmentRecordQuery) + syncOrchestrator.execute(OneTime.Events.start()) } } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt deleted file mode 100644 index 8688c1d953..0000000000 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.simprints.feature.validatepool.usecase - -import com.simprints.infra.sync.OneTime -import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.extensions.await -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -internal class RunBlockingEventSyncUseCase @Inject constructor( - private val syncOrchestrator: SyncOrchestrator, -) { - suspend operator fun invoke() { - val syncState = syncOrchestrator.observeSyncState() - // First item in the flow (except uninitialized) is the state of last sync, - // so it can be used to as a filter out old sync states. - // To guarantee it's not associated with the newly run sync, - // the value needs to be taken before it starts. - val lastSyncId = syncState - .map { it.eventSyncState } - .firstOrNull { !it.isUninitialized() } - ?.syncId - syncOrchestrator - .execute(OneTime.Events.start()) - .await() - syncState - .map { it.eventSyncState } - .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } - } -} diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt index d96df5409c..9f2d3f916b 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt @@ -1,22 +1,26 @@ package com.simprints.feature.validatepool.screen import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.* import com.jraska.livedata.test import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.feature.validatepool.usecase.HasRecordsUseCase import com.simprints.feature.validatepool.usecase.IsModuleIdNotSyncedUseCase -import com.simprints.feature.validatepool.usecase.RunBlockingEventSyncUseCase import com.simprints.feature.validatepool.usecase.ShouldSuggestSyncUseCase import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecordQuery +import com.simprints.infra.eventsync.status.models.EventSyncState +import com.simprints.infra.eventsync.status.models.EventSyncWorkerState +import com.simprints.infra.eventsync.status.models.EventSyncWorkerType +import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncStatus import com.simprints.testtools.common.coroutines.TestCoroutineRule -import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.coJustRun -import io.mockk.coVerify +import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before @@ -40,7 +44,9 @@ class ValidateSubjectPoolViewModelTest { private lateinit var shouldSuggestSyncUseCase: ShouldSuggestSyncUseCase @MockK - private lateinit var runBlockingSync: RunBlockingEventSyncUseCase + private lateinit var syncOrchestrator: SyncOrchestrator + + private lateinit var syncStatusFlow: MutableStateFlow private lateinit var viewModel: ValidateSubjectPoolViewModel @@ -48,11 +54,14 @@ class ValidateSubjectPoolViewModelTest { fun setUp() { MockKAnnotations.init(this) + syncStatusFlow = MutableStateFlow(createSyncStatus(isCompleted = false)) + every { syncOrchestrator.observeSyncState() } returns syncStatusFlow + viewModel = ValidateSubjectPoolViewModel( hasRecordsUseCase, isModuleIdNotSyncedUseCase, shouldSuggestSyncUseCase, - runBlockingSync, + syncOrchestrator, ) } @@ -163,38 +172,75 @@ class ValidateSubjectPoolViewModelTest { assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.PoolEmpty) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `runs sync and check`() = runTest { val enrolmentRecordQuery = EnrolmentRecordQuery(projectId = "projectId") + val job = Job() coEvery { hasRecordsUseCase(any()) } returnsMany listOf(true) - coJustRun { runBlockingSync() } + every { syncOrchestrator.execute(any()) } returns job val result = viewModel.state.test() - viewModel.syncAndRetry(enrolmentRecordQuery) + viewModel.startSync(enrolmentRecordQuery) + + syncStatusFlow.value = createSyncStatus(isCompleted = true) + job.complete() + advanceUntilIdle() assertThat(result.valueHistory().map { it.peekContent() }).containsExactly( ValidateSubjectPoolState.SyncInProgress, ValidateSubjectPoolState.Validating, ValidateSubjectPoolState.Success, ) - coVerify(exactly = 1) { runBlockingSync() } + coVerify(exactly = 1) { syncOrchestrator.execute(any()) } } @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `checkIdentificationPool not re-validating another time when sync in progress`() = runTest { + fun `checkIdentificationPool not re-validating when sync in progress`() = runTest { val enrolmentRecordQuery = EnrolmentRecordQuery(projectId = "projectId") + val job = Job() coEvery { hasRecordsUseCase(enrolmentRecordQuery) } returns true - coEvery { runBlockingSync() } coAnswers { - delay(1000) - } - viewModel.syncAndRetry(enrolmentRecordQuery) + every { syncOrchestrator.execute(any()) } returns job + viewModel.startSync(enrolmentRecordQuery) viewModel.checkIdentificationPool(enrolmentRecordQuery) + job.complete() advanceUntilIdle() - coVerify(exactly = 1) { hasRecordsUseCase(any()) } + coVerify(exactly = 1) { syncOrchestrator.execute(any()) } + coVerify(exactly = 0) { hasRecordsUseCase(any()) } + } + + private fun createSyncStatus(isCompleted: Boolean): SyncStatus { + val reporterStates = if (isCompleted) { + listOf( + EventSyncState.SyncWorkerInfo( + type = EventSyncWorkerType.END_SYNC_REPORTER, + state = EventSyncWorkerState.Succeeded, + ), + ) + } else { + emptyList() + } + + return SyncStatus( + eventSyncState = EventSyncState( + syncId = "", + progress = null, + total = null, + upSyncWorkersInfo = emptyList(), + downSyncWorkersInfo = emptyList(), + reporterStates = reporterStates, + lastSyncTime = null, + ), + imageSyncStatus = ImageSyncStatus( + isSyncing = false, + progress = null, + lastUpdateTimeMillis = null, + ), + ) } } diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt deleted file mode 100644 index a3908f2890..0000000000 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.simprints.feature.validatepool.usecase - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.simprints.infra.eventsync.status.models.EventSyncState -import com.simprints.infra.eventsync.status.models.EventSyncWorkerState -import com.simprints.infra.eventsync.status.models.EventSyncWorkerType -import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.OneTime -import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.SyncStatus -import com.simprints.testtools.common.coroutines.TestCoroutineRule -import io.mockk.* -import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -class RunBlockingEventSyncUseCaseTest { - @get:Rule - val rule = InstantTaskExecutorRule() - - @get:Rule - val testCoroutineRule = TestCoroutineRule() - - @MockK - private lateinit var syncOrchestrator: SyncOrchestrator - - private lateinit var usecase: RunBlockingEventSyncUseCase - - @Before - fun setUp() { - MockKAnnotations.init(this) - - usecase = RunBlockingEventSyncUseCase(syncOrchestrator) - } - - @Test - fun `finishes execution when sync reporters are finished`() = runTest { - val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - setUpSync(syncFlow) - - launch { usecase.invoke() } - testScheduler.advanceUntilIdle() - - syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) - testScheduler.advanceUntilIdle() - - verify(exactly = 1) { syncOrchestrator.observeSyncState() } - verify(exactly = 1) { syncOrchestrator.execute(OneTime.Events.start()) } - } - - @Test - fun `finishes execution when sync reporters have failed`() = runTest { - val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - setUpSync(syncFlow) - - launch { usecase.invoke() } - testScheduler.advanceUntilIdle() - - syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Failed()) - testScheduler.advanceUntilIdle() - - verify(exactly = 1) { syncOrchestrator.observeSyncState() } - verify(exactly = 1) { syncOrchestrator.execute(OneTime.Events.start()) } - } - - @Test - fun `finishes execution when sync reporters have been cancelled`() = runTest { - val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - setUpSync(syncFlow) - - launch { usecase.invoke() } - testScheduler.advanceUntilIdle() - - syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Cancelled) - testScheduler.advanceUntilIdle() - - verify(exactly = 1) { syncOrchestrator.observeSyncState() } - verify(exactly = 1) { syncOrchestrator.execute(OneTime.Events.start()) } - } - - @Test - fun `does not start sync early when initial default state is emitted before last completed sync`() = runTest { - val syncFlow = MutableStateFlow(createPlaceholderSyncStatus()) - setUpSync(syncFlow) - - val job = launch { usecase.invoke() } - testScheduler.advanceUntilIdle() - - verify(exactly = 0) { syncOrchestrator.execute(OneTime.Events.start()) } - - syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) - testScheduler.advanceUntilIdle() - - verify(exactly = 1) { syncOrchestrator.execute(OneTime.Events.start()) } - job.cancel() - } - - private fun createSyncStatus( - syncId: String, - endReporterState: EventSyncWorkerState?, - progress: Int? = 0, - total: Int? = 0, - ): SyncStatus { - val eventSyncState = EventSyncState( - syncId, - progress, - total, - emptyList(), - emptyList(), - listOfNotNull( - endReporterState?.let { - EventSyncState.SyncWorkerInfo(EventSyncWorkerType.END_SYNC_REPORTER, it) - }, - ), - null, - ) - return SyncStatus( - eventSyncState = eventSyncState, - imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, lastUpdateTimeMillis = null), - ) - } - - private fun setUpSync(syncFlow: StateFlow) { - every { syncOrchestrator.observeSyncState() } returns syncFlow - every { syncOrchestrator.execute(OneTime.Events.start()) } returns Job().apply { complete() } - } - - private fun createPlaceholderSyncStatus(): SyncStatus = createSyncStatus("", null, null, null) -} From 08aee66a267a4c26a760b89bc9dbe05e6463a08d Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 18 May 2026 18:31:42 +0300 Subject: [PATCH 03/14] MS-1431 Reset the pool validation to SyncInProgress state when called during sync --- .../feature/validatepool/screen/ValidateSubjectPoolViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt index 1247b193cf..4603022798 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt @@ -50,6 +50,8 @@ internal class ValidateSubjectPoolViewModel @Inject constructor( fun checkIdentificationPool(enrolmentRecordQuery: EnrolmentRecordQuery) = viewModelScope.launch { if (isSyncing) { + // In case of configuration change while syncing, we want to show the sync in progress state instead of default state + _state.send(ValidateSubjectPoolState.SyncInProgress) return@launch } _state.send(ValidateSubjectPoolState.Validating) From ce8fc1a49d41d4ce2f2ac0ae644b3fe53c1a6173 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 18 May 2026 10:20:41 +0300 Subject: [PATCH 04/14] MS-1411 Use timestamp model for last location time --- .../feature/setup/location/LocationManager.kt | 9 ++++++++- .../feature/setup/location/LocationManagerTest.kt | 6 +++++- .../event/remote/models/session/ApiLocation.kt | 13 ++++++++++++- .../usecases/MapDomainEventScopeToApiUseCaseTest.kt | 9 +++++++-- .../events/event/domain/models/scope/Location.kt | 3 ++- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/feature/setup/src/main/java/com/simprints/feature/setup/location/LocationManager.kt b/feature/setup/src/main/java/com/simprints/feature/setup/location/LocationManager.kt index d6037f3eda..33d8d0fe6a 100644 --- a/feature/setup/src/main/java/com/simprints/feature/setup/location/LocationManager.kt +++ b/feature/setup/src/main/java/com/simprints/feature/setup/location/LocationManager.kt @@ -5,6 +5,7 @@ import android.content.Context import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.tasks.CancellationTokenSource +import com.simprints.core.tools.time.Timestamp import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.experimental import com.simprints.infra.events.event.domain.models.scope.Location @@ -15,6 +16,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await import javax.inject.Inject +import kotlin.time.DurationUnit +import kotlin.time.toDuration internal class LocationManager @Inject constructor( @param:ApplicationContext val ctx: Context, @@ -44,7 +47,11 @@ internal class LocationManager @Inject constructor( Location( latitude = lastLocation.latitude, longitude = lastLocation.longitude, - lastLocationTime = lastLocation.time, + lastLocationTime = Timestamp( + ms = lastLocation.time, + isTrustworthy = false, + msSinceBoot = lastLocation.elapsedRealtimeNanos.toDuration(DurationUnit.NANOSECONDS).inWholeMilliseconds, + ), ), ) } diff --git a/feature/setup/src/test/java/com/simprints/feature/setup/location/LocationManagerTest.kt b/feature/setup/src/test/java/com/simprints/feature/setup/location/LocationManagerTest.kt index 20fdd08287..c386e6bdc5 100644 --- a/feature/setup/src/test/java/com/simprints/feature/setup/location/LocationManagerTest.kt +++ b/feature/setup/src/test/java/com/simprints/feature/setup/location/LocationManagerTest.kt @@ -77,7 +77,7 @@ internal class LocationManagerTest { assertThat(results[0]?.latitude).isEqualTo(10.0) assertThat(results[0]?.longitude).isEqualTo(10.0) - assertThat(results[0]?.lastLocationTime).isEqualTo(1000L) + assertThat(results[0]?.lastLocationTime?.ms).isEqualTo(1000L) assertThat(results[1]?.latitude).isEqualTo(20.0) assertThat(results[1]?.longitude).isEqualTo(20.0) @@ -112,6 +112,8 @@ internal class LocationManagerTest { val fakeLastLocation = TestLocationData.buildFakeLocation().apply { latitude = 40.0 longitude = 40.0 + time = 1000L + elapsedRealtimeNanos = 3000_000_000L // Using nanos since it is supported on older API levels } every { mockedLocationClient.lastLocation } returns Tasks.forResult(fakeLastLocation) @@ -126,6 +128,8 @@ internal class LocationManagerTest { assertThat(results[0]?.latitude).isEqualTo(40.0) assertThat(results[0]?.longitude).isEqualTo(40.0) + assertThat(results[0]?.lastLocationTime?.ms).isEqualTo(1000L) + assertThat(results[0]?.lastLocationTime?.msSinceBoot).isEqualTo(3000L) } @Test diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/session/ApiLocation.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/session/ApiLocation.kt index 743aa075ac..e39a0a11f9 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/session/ApiLocation.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/session/ApiLocation.kt @@ -2,6 +2,8 @@ package com.simprints.infra.eventsync.event.remote.models.session import androidx.annotation.Keep import com.simprints.infra.events.event.domain.models.scope.Location +import com.simprints.infra.eventsync.event.remote.models.ApiTimestamp +import com.simprints.infra.eventsync.event.remote.models.fromDomainToApi import kotlinx.serialization.Serializable @Keep @@ -9,6 +11,15 @@ import kotlinx.serialization.Serializable internal data class ApiLocation( var latitude: Double = 0.0, var longitude: Double = 0.0, + val lastLocationTime: ApiTimestamp? = null, + var noPermission: Boolean? = null, ) -internal fun Location?.fromDomainToApi() = this?.let { ApiLocation(latitude, longitude) } +internal fun Location?.fromDomainToApi() = this?.let { + ApiLocation( + latitude = latitude, + longitude = longitude, + lastLocationTime = lastLocationTime?.fromDomainToApi(), + noPermission = noPermission, + ) +} diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventScopeToApiUseCaseTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventScopeToApiUseCaseTest.kt index 15d5550b9b..c3389321e9 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventScopeToApiUseCaseTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventScopeToApiUseCaseTest.kt @@ -8,6 +8,7 @@ import com.simprints.infra.events.event.domain.models.scope.Device import com.simprints.infra.events.event.domain.models.scope.EventScope import com.simprints.infra.events.event.domain.models.scope.EventScopePayload import com.simprints.infra.events.event.domain.models.scope.EventScopeType +import com.simprints.infra.events.event.domain.models.scope.Location import com.simprints.infra.eventsync.event.remote.models.ApiEvent import io.mockk.* import io.mockk.impl.annotations.MockK @@ -57,7 +58,7 @@ internal class MapDomainEventScopeToApiUseCaseTest { assertEquals(id, scope.id) assertEquals(projectId, scope.projectId) assertEquals(startTime.unixMs, scope.createdAt.ms) - assertEquals(endTime, scope.endedAt) + assertEquals(endTime?.unixMs, scope.endedAt?.ms) } } @@ -77,7 +78,11 @@ internal class MapDomainEventScopeToApiUseCaseTest { databaseInfo = DatabaseInfo(0, 0), projectConfigurationUpdatedAt = "", projectConfigurationId = "", - location = null, + location = Location( + latitude = 56.0, + longitude = 27.0, + lastLocationTime = Timestamp(1000L), + ), ), ) } diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/scope/Location.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/scope/Location.kt index 4a71a87059..b1f6417953 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/scope/Location.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/scope/Location.kt @@ -1,6 +1,7 @@ package com.simprints.infra.events.event.domain.models.scope import androidx.annotation.Keep +import com.simprints.core.tools.time.Timestamp import kotlinx.serialization.Serializable @Keep @@ -8,7 +9,7 @@ import kotlinx.serialization.Serializable data class Location( var latitude: Double = 0.0, var longitude: Double = 0.0, - val lastLocationTime: Long? = null, + val lastLocationTime: Timestamp? = null, val noPermission: Boolean? = null, ) { companion object { From 7daa6896bd90467fc125673c0bc89657a6476686 Mon Sep 17 00:00:00 2001 From: Marinov Date: Tue, 19 May 2026 12:16:30 +0300 Subject: [PATCH 05/14] [MS-1036] Close ImageProxy even when preview is empty to avoid leaks --- .../CropToTargetOverlayAnalyzer.kt | 59 +++++++++---------- .../CropToTargetOverlayAnalyzerTest.kt | 19 ++++++ 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt index c4ceae3db2..27dbf489d3 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt @@ -12,40 +12,39 @@ internal class CropToTargetOverlayAnalyzer( private val onImageCropped: (Bitmap) -> Unit, ) : ImageAnalysis.Analyzer { override fun analyze(image: ImageProxy) { - val previewRect = targetOverlay.circleRect - if (previewRect.isEmpty) return + val croppedBitmap = image.use { + val previewRect = targetOverlay.circleRect + if (previewRect.isEmpty) return - // Adjust overlay size to be fit-center with the image size - val scale = getSmallerRatio( - image.width, - image.height, - targetOverlay.width, - targetOverlay.height, - ) - val scaledWidth = (targetOverlay.width * scale).toInt() - val scaledHeight = (targetOverlay.height * scale).toInt() + // Adjust overlay size to be fit-center with the image size + val scale = getSmallerRatio( + it.width, + it.height, + targetOverlay.width, + targetOverlay.height, + ) + val scaledWidth = (targetOverlay.width * scale).toInt() + val scaledHeight = (targetOverlay.height * scale).toInt() - // Find the offsets caused by fit-center scaling - val offsetX = (max(image.width, scaledWidth) - min(image.width, scaledWidth)) / 2 - val offsetY = (max(image.height, scaledHeight) - min(image.height, scaledHeight)) / 2 + // Find the offsets caused by fit-center scaling + val offsetX = (max(it.width, scaledWidth) - min(it.width, scaledWidth)) / 2 + val offsetY = (max(it.height, scaledHeight) - min(it.height, scaledHeight)) / 2 - // Scale the preview target to the new scale and offset - val cropLeft = offsetX + (previewRect.left * scale).toInt() - val cropWidth = (previewRect.width() * scale).toInt() - val cropTop = offsetY + (previewRect.top * scale).toInt() - val cropHeight = (previewRect.height() * scale).toInt() + // Scale the preview target to the new scale and offset + val cropLeft = offsetX + (previewRect.left * scale).toInt() + val cropWidth = (previewRect.width() * scale).toInt() + val cropTop = offsetY + (previewRect.top * scale).toInt() + val cropHeight = (previewRect.height() * scale).toInt() - onImageCropped( - image.use { - Bitmap.createBitmap( - it.toBitmap(), - cropLeft, - cropTop, - cropWidth, - cropHeight, - ) - }, - ) + Bitmap.createBitmap( + it.toBitmap(), + cropLeft, + cropTop, + cropWidth, + cropHeight, + ) + } + onImageCropped(croppedBitmap) } private fun getSmallerRatio( diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt index cd6d3a1eaf..aef91be037 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt @@ -10,6 +10,7 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.justRun +import io.mockk.verify import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -46,6 +47,7 @@ internal class CropToTargetOverlayAnalyzerTest { // Cropped should be still square and half the side length of original assertThat(capturedBitmap?.width).isNull() assertThat(capturedBitmap?.height).isNull() + verify(exactly = 1) { imageProxy.close() } } @Test @@ -62,6 +64,23 @@ internal class CropToTargetOverlayAnalyzerTest { assertThat(capturedBitmap?.height).isEqualTo(300) } + @Test + fun `Closes ImageProxy before invoking cropped callback`() { + setupScreenSize(1000, 2000) + every { targetOverlay.circleRect } returns RectF(200f, 200f, 800f, 800f) + setupImageSize(1000, 1000) + + var closed = false + every { imageProxy.close() } answers { closed = true } + var closedBeforeCallback = false + val analyzer = CropToTargetOverlayAnalyzer(targetOverlay) { closedBeforeCallback = closed } + + analyzer.analyze(imageProxy) + + assertThat(closedBeforeCallback).isTrue() + verify(exactly = 1) { imageProxy.close() } + } + @Test fun `Correctly crops when camera resolution is smaller than preview in landscape`() { // Target is a square 600x600px with 200px from top bounds From bb87640c5d6d0fe491ec57c3c663da0872ddbf00 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 19 May 2026 15:53:54 +0300 Subject: [PATCH 06/14] MS-1433 Persist data for MFID analytics events across activity recreation --- .../controller/ExternalCredentialViewModel.kt | 20 ++++ .../ExternalCredentialViewModelTest.kt | 91 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt index d1673452d3..0d6701efa0 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -2,6 +2,7 @@ package com.simprints.feature.externalcredential.screens.controller import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.simprints.core.domain.externalcredential.ExternalCredentialType @@ -23,6 +24,7 @@ internal class ExternalCredentialViewModel @Inject internal constructor( private val timeHelper: TimeHelper, private val configRepository: ConfigRepository, private val eventsTracker: ExternalCredentialEventTrackerUseCase, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { private var isInitialized = false lateinit var params: ExternalCredentialParams @@ -50,6 +52,10 @@ internal class ExternalCredentialViewModel @Inject internal constructor( private var selectedSkipOtherText: String? = null init { + savedStateHandle.get(KEY_SELECTION_START_TIME)?.let { selectionStartTime = it } + savedStateHandle.get(KEY_SELECTION_EVENT_ID)?.let { selectionEventId = it } + savedStateHandle.get(KEY_CAPTURE_START_TIME)?.let { captureStartTime = it } + viewModelScope.launch { val config = configRepository.getProjectConfiguration() val allowedExternalCredentials = config.multifactorId?.allowedExternalCredentials.orEmpty() @@ -58,7 +64,13 @@ internal class ExternalCredentialViewModel @Inject internal constructor( } fun selectionStarted() { + if (savedStateHandle.contains(KEY_SELECTION_START_TIME)) { + // Selection time has been recorded, which means the user has already started selection before process death. + return + } + selectionStartTime = timeHelper.now() + savedStateHandle[KEY_SELECTION_START_TIME] = selectionStartTime } fun skipOptionSelected(skipOption: ExternalCredentialSelectionEvent.SkipReason) { @@ -78,7 +90,9 @@ internal class ExternalCredentialViewModel @Inject internal constructor( if (selectedType != null) { val selectionEndTime = timeHelper.now() selectionEventId = eventsTracker.saveSelectionEvent(selectionStartTime, selectionEndTime, selectedType) + savedStateHandle[KEY_SELECTION_EVENT_ID] = selectionEventId captureStartTime = timeHelper.now() + savedStateHandle[KEY_CAPTURE_START_TIME] = captureStartTime } updateState { it.copy(selectedType = selectedType) } } @@ -118,4 +132,10 @@ internal class ExternalCredentialViewModel @Inject internal constructor( _finishEvent.send(result) } } + + internal companion object { + internal const val KEY_SELECTION_START_TIME = "selection_start_time" + internal const val KEY_SELECTION_EVENT_ID = "selection_event_id" + internal const val KEY_CAPTURE_START_TIME = "capture_start_time" + } } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt index d4a39d45ed..10adf0c109 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt @@ -1,6 +1,7 @@ package com.simprints.feature.externalcredential.screens.controller import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.* import com.jraska.livedata.test import com.simprints.core.domain.common.FlowType @@ -37,19 +38,108 @@ internal class ExternalCredentialViewModelTest { @MockK private lateinit var configRepository: ConfigRepository + private lateinit var savedStateHandle: SavedStateHandle private lateinit var viewModel: ExternalCredentialViewModel @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + savedStateHandle = SavedStateHandle() viewModel = ExternalCredentialViewModel( configRepository = configRepository, timeHelper = timeHelper, eventsTracker = eventsTracker, + savedStateHandle = savedStateHandle, ) every { timeHelper.now() } returns Timestamp(1L) } + @Test + fun `selectionStarted persists selectionStartTime to savedStateHandle`() { + val expected = Timestamp(11L) + every { timeHelper.now() } returns expected + + viewModel.selectionStarted() + + assertThat(savedStateHandle.get(ExternalCredentialViewModel.KEY_SELECTION_START_TIME)) + .isEqualTo(expected) + } + + @Test + fun `setSelectedExternalCredentialType persists selection event id and capture start time`() = runTest { + val selectionStartTime = Timestamp(10L) + val selectionEndTime = Timestamp(20L) + val captureStartTime = Timestamp(30L) + coEvery { eventsTracker.saveSelectionEvent(any(), any(), any()) } returns "selection-id" + every { timeHelper.now() } returnsMany listOf(selectionStartTime, selectionEndTime, captureStartTime) + + viewModel.selectionStarted() + viewModel.setSelectedExternalCredentialType(ExternalCredentialType.QRCode) + + assertThat(savedStateHandle.get(ExternalCredentialViewModel.KEY_SELECTION_EVENT_ID)) + .isEqualTo("selection-id") + assertThat(savedStateHandle.get(ExternalCredentialViewModel.KEY_CAPTURE_START_TIME)) + .isEqualTo(captureStartTime) + } + + @Test + fun `finish complete uses restored selection event state from savedStateHandle`() = runTest { + val restoredCaptureStartTime = Timestamp(123L) + val restoredSelectionEventId = "restored-selection-id" + val restoredStateHandle = SavedStateHandle( + mapOf( + ExternalCredentialViewModel.KEY_CAPTURE_START_TIME to restoredCaptureStartTime, + ExternalCredentialViewModel.KEY_SELECTION_EVENT_ID to restoredSelectionEventId, + ), + ) + val restoredViewModel = ExternalCredentialViewModel( + configRepository = configRepository, + timeHelper = timeHelper, + eventsTracker = eventsTracker, + savedStateHandle = restoredStateHandle, + ) + val result = mockk(relaxed = true) { + every { scannedCredentialResult } returns mockk(relaxed = true) + } + + restoredViewModel.init(createParams(subjectId = "subjectId", flowType = FlowType.IDENTIFY)) + restoredViewModel.finish(result) + + coVerify { + eventsTracker.saveCaptureEvents( + credentialSearchResult = result, + subjectId = "subjectId", + startTime = restoredCaptureStartTime, + selectionEventId = restoredSelectionEventId, + ) + } + } + + @Test + fun `finish skipped uses restored selection start time from savedStateHandle`() = runTest { + val restoredSelectionStartTime = Timestamp(456L) + val restoredStateHandle = SavedStateHandle( + mapOf(ExternalCredentialViewModel.KEY_SELECTION_START_TIME to restoredSelectionStartTime), + ) + val restoredViewModel = ExternalCredentialViewModel( + configRepository = configRepository, + timeHelper = timeHelper, + eventsTracker = eventsTracker, + savedStateHandle = restoredStateHandle, + ) + val result = mockk(relaxed = true) + + restoredViewModel.finish(result) + + coVerify { + eventsTracker.saveSkippedEvent( + startTime = restoredSelectionStartTime, + skipReason = result.skipReason, + skipOther = null, + ) + } + } + @Test fun `initial state is EMPTY`() { val observer = viewModel.stateLiveData.test() @@ -195,6 +285,7 @@ internal class ExternalCredentialViewModelTest { configRepository = configRepository, timeHelper = timeHelper, eventsTracker = eventsTracker, + savedStateHandle = SavedStateHandle(), ) } From de0bab1071744b98d3817a63aafe55293827dead Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Fri, 22 May 2026 17:13:25 +0100 Subject: [PATCH 07/14] [MS-1442] Use viewLifecycleOwner for camera binding and move executor shutdown to onDestroyView in LiveFeedbackFragment --- .../screens/livefeedback/LiveFeedbackFragment.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt index db9f7b8d5e..39ecfda04d 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt @@ -184,7 +184,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) val cameraProvider = ProcessCameraProvider.awaitInstance(requireContext()) cameraProvider.unbindAll() val camera = cameraProvider.bindToLifecycle( - this@LiveFeedbackFragment, + viewLifecycleOwner, DEFAULT_BACK_CAMERA, preview, imageAnalyzer, @@ -227,11 +227,14 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) override fun onStop() { toggleTorche(false) - // Shut down our background executor - if (::cameraExecutor.isInitialized) { + super.onStop() + } + + override fun onDestroyView() { + if (::cameraExecutor.isInitialized && !cameraExecutor.isShutdown) { cameraExecutor.shutdown() } - super.onStop() + super.onDestroyView() } private fun bindViewModel() { From c44957139acdf5e48f8643df1d34c50256c1adc0 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Fri, 22 May 2026 17:18:24 +0100 Subject: [PATCH 08/14] [MS-1443] Fix RecyclerView adapter and animation leaks in SyncInfoFragment --- .../settings/syncinfo/SyncInfoFragment.kt | 27 +++++++++++-------- .../FragmentViewBindingDelegate.kt | 12 +++------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt index a0d2625e39..e60f750ff6 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt @@ -74,6 +74,13 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { ) } + override fun onDestroyView() { + binding.selectedModulesView.adapter = null + binding.eventSyncProgressBar.setPulseAnimation(isEnabled = false) + binding.imageSyncProgressBar.setPulseAnimation(isEnabled = false) + super.onDestroyView() + } + private fun setupClickListeners() { binding.buttonSelectModules.setOnClickListener { findNavController().navigate(R.id.moduleSelectionFragment) @@ -126,8 +133,8 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { } private fun observeUI() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { renderSyncInfo(SyncInfo(), syncInfoConfig) viewModel.syncInfoLiveData.observe(viewLifecycleOwner) { syncInfo -> renderSyncInfo(syncInfo, syncInfoConfig) @@ -316,16 +323,14 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { moduleCountAdapter.submitList(modules.moduleCounts) - // RecyclerView height fix (wrong height may be caused by ConstraintLayout in parent views) - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - val itemHeight = resources.getDimensionPixelSize(R.dimen.module_item_height) - val itemCount = modules.moduleCounts.size.coerceAtMost(MAX_MODULE_LIST_HEIGHT_ITEMS) - binding.selectedModulesView.apply { - layoutParams = layoutParams.apply { - height = itemHeight * itemCount - } + val itemHeight = resources.getDimensionPixelSize(R.dimen.module_item_height) + val itemCount = modules.moduleCounts.size.coerceAtMost(MAX_MODULE_LIST_HEIGHT_ITEMS) + binding.selectedModulesView.let { recyclerView -> + recyclerView.post { + recyclerView.layoutParams = recyclerView.layoutParams.apply { + height = itemHeight * itemCount } + recyclerView.requestLayout() } } } diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/viewbinding/FragmentViewBindingDelegate.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/viewbinding/FragmentViewBindingDelegate.kt index c84953e0c3..3e9d39e6d7 100644 --- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/viewbinding/FragmentViewBindingDelegate.kt +++ b/infra/ui-base/src/main/java/com/simprints/infra/uibase/viewbinding/FragmentViewBindingDelegate.kt @@ -22,14 +22,10 @@ class FragmentViewBindingDelegate( private var binding: T? = null private val fragmentObserver = object : DefaultLifecycleObserver { - val viewLifecycleOwnerLiveDataObserver = Observer { - val viewLifecycleOwner = it ?: return@Observer - - viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - }) + val viewLifecycleOwnerLiveDataObserver = Observer { owner -> + if (owner == null) { + binding = null + } } override fun onCreate(owner: LifecycleOwner) { From a7ccdf5a504f8c629b87d1714f3762085cc73702 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Fri, 22 May 2026 18:06:53 +0100 Subject: [PATCH 09/14] [MS-1442] Refactor CropToTargetOverlayAnalyzer to decouple from View and improve CameraX resource cleanup --- .../CropToTargetOverlayAnalyzer.kt | 15 ++++++------- .../livefeedback/LiveFeedbackFragment.kt | 21 ++++++++++++++++--- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt index 27dbf489d3..768bb53d1d 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt @@ -1,30 +1,31 @@ package com.simprints.face.capture.screens.livefeedback import android.graphics.Bitmap +import android.graphics.RectF import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy -import com.simprints.face.capture.screens.livefeedback.views.CameraTargetOverlay import kotlin.math.max import kotlin.math.min internal class CropToTargetOverlayAnalyzer( - private val targetOverlay: CameraTargetOverlay, + private val previewRect: RectF, + private val overlayWidth: Int, + private val overlayHeight: Int, private val onImageCropped: (Bitmap) -> Unit, ) : ImageAnalysis.Analyzer { override fun analyze(image: ImageProxy) { val croppedBitmap = image.use { - val previewRect = targetOverlay.circleRect if (previewRect.isEmpty) return // Adjust overlay size to be fit-center with the image size val scale = getSmallerRatio( it.width, it.height, - targetOverlay.width, - targetOverlay.height, + overlayWidth, + overlayHeight, ) - val scaledWidth = (targetOverlay.width * scale).toInt() - val scaledHeight = (targetOverlay.height * scale).toInt() + val scaledWidth = (overlayWidth * scale).toInt() + val scaledHeight = (overlayHeight * scale).toInt() // Find the offsets caused by fit-center scaling val offsetX = (max(it.width, scaledWidth) - min(it.width, scaledWidth)) / 2 diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt index 39ecfda04d..258b1430c6 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt @@ -67,6 +67,8 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private lateinit var screenSize: Size private lateinit var targetResolution: Size + private lateinit var imageAnalyzer: ImageAnalysis + private lateinit var preview: Preview private var cameraControl: CameraControl? = null @@ -166,18 +168,24 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) ), ).build() - val imageAnalyzer = ImageAnalysis + imageAnalyzer = ImageAnalysis .Builder() .setResolutionSelector(resolutionSelector) .setOutputImageRotationEnabled(true) .setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888) .build() - val cropAnalyzer = CropToTargetOverlayAnalyzer(binding.captureOverlay, ::analyze) + + val cropAnalyzer = CropToTargetOverlayAnalyzer( + previewRect = binding.captureOverlay.circleRect, + overlayWidth = binding.captureOverlay.width, + overlayHeight = binding.captureOverlay.height, + onImageCropped = ::analyze, + ) imageAnalyzer.setAnalyzer(cameraExecutor, cropAnalyzer) // Preview - val preview = Preview + preview = Preview .Builder() .setResolutionSelector(resolutionSelector) .build() @@ -234,6 +242,13 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) if (::cameraExecutor.isInitialized && !cameraExecutor.isShutdown) { cameraExecutor.shutdown() } + if (::imageAnalyzer.isInitialized) { + imageAnalyzer.clearAnalyzer() + } + + if (::preview.isInitialized) { + preview.surfaceProvider = null + } super.onDestroyView() } From 43d0835692c916673250728a80947042b981cfa2 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Fri, 22 May 2026 18:36:55 +0100 Subject: [PATCH 10/14] Simplify LiveData observation in SyncInfoFragment by removing redundant lifecycle scoping --- .../settings/syncinfo/SyncInfoFragment.kt | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt index e60f750ff6..91451011bf 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt @@ -14,9 +14,6 @@ import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.android.material.progressindicator.LinearProgressIndicator import com.simprints.core.tools.utils.TimeUtils @@ -31,7 +28,6 @@ import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.view.setPulseAnimation import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import com.simprints.infra.resources.R as IDR @AndroidEntryPoint @@ -133,15 +129,10 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { } private fun observeUI() { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { - renderSyncInfo(SyncInfo(), syncInfoConfig) - viewModel.syncInfoLiveData.observe(viewLifecycleOwner) { syncInfo -> - renderSyncInfo(syncInfo, syncInfoConfig) - } - } + renderSyncInfo(SyncInfo(), syncInfoConfig) + viewModel.syncInfoLiveData.observe(viewLifecycleOwner) { syncInfo -> + renderSyncInfo(syncInfo, syncInfoConfig) } - viewModel.loginNavigationEventLiveData.observe(viewLifecycleOwner) { loginParams -> findNavController().navigate(com.simprints.feature.login.R.id.graph_login, loginParams.toBundle()) } From feed934a5dc4aaaaa15e5a37fc0acd3a5f706cf9 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Fri, 22 May 2026 18:58:51 +0100 Subject: [PATCH 11/14] Refactor `CropToTargetOverlayAnalyzer` to use explicit dimensions and coordinates --- .../livefeedback/LiveFeedbackFragment.kt | 3 +- .../CropToTargetOverlayAnalyzerTest.kt | 64 +++++++++---------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt index 258b1430c6..df74fb0b8f 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt @@ -3,6 +3,7 @@ package com.simprints.face.capture.screens.livefeedback import android.Manifest import android.content.Intent import android.graphics.Bitmap +import android.graphics.RectF import android.os.Bundle import android.provider.Settings import android.util.Size @@ -176,7 +177,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) .build() val cropAnalyzer = CropToTargetOverlayAnalyzer( - previewRect = binding.captureOverlay.circleRect, + previewRect = RectF(binding.captureOverlay.circleRect), // create a new instance to avoid threading issues overlayWidth = binding.captureOverlay.width, overlayHeight = binding.captureOverlay.height, onImageCropped = ::analyze, diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt index aef91be037..14f2c34f5f 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt @@ -5,7 +5,6 @@ import android.graphics.RectF import androidx.camera.core.ImageProxy import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.simprints.face.capture.screens.livefeedback.views.CameraTargetOverlay import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK @@ -17,9 +16,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) internal class CropToTargetOverlayAnalyzerTest { - @MockK - lateinit var targetOverlay: CameraTargetOverlay - @MockK lateinit var imageProxy: ImageProxy @@ -30,17 +26,17 @@ internal class CropToTargetOverlayAnalyzerTest { fun setUp() { MockKAnnotations.init(this) justRun { imageProxy.close() } - capturedBitmap = null - analyzer = CropToTargetOverlayAnalyzer(targetOverlay) { capturedBitmap = it } } @Test fun `Skip cropping when target is empty`() { - // Target is a square 600x600px with 200px from top bounds - setupScreenSize(1000, 2000) - every { targetOverlay.circleRect } returns RectF(200f, 200f, 200f, 200f) setupImageSize(1000, 1000) + analyzer = CropToTargetOverlayAnalyzer( + previewRect = RectF(200f, 200f, 200f, 200f), + overlayWidth = 1000, + overlayHeight = 2000, + ) { capturedBitmap = it } analyzer.analyze(imageProxy) @@ -52,10 +48,12 @@ internal class CropToTargetOverlayAnalyzerTest { @Test fun `Correctly crops when camera resolution is smaller than preview in portrait`() { - // Target is a square 600x600px with 200px from top bounds - setupScreenSize(1000, 2000) - every { targetOverlay.circleRect } returns RectF(200f, 200f, 800f, 800f) setupImageSize(1000, 1000) + analyzer = CropToTargetOverlayAnalyzer( + previewRect = RectF(200f, 200f, 800f, 800f), + overlayWidth = 1000, + overlayHeight = 2000, + ) { capturedBitmap = it } analyzer.analyze(imageProxy) @@ -66,14 +64,16 @@ internal class CropToTargetOverlayAnalyzerTest { @Test fun `Closes ImageProxy before invoking cropped callback`() { - setupScreenSize(1000, 2000) - every { targetOverlay.circleRect } returns RectF(200f, 200f, 800f, 800f) setupImageSize(1000, 1000) - var closed = false every { imageProxy.close() } answers { closed = true } var closedBeforeCallback = false - val analyzer = CropToTargetOverlayAnalyzer(targetOverlay) { closedBeforeCallback = closed } + + val analyzer = CropToTargetOverlayAnalyzer( + previewRect = RectF(200f, 200f, 800f, 800f), + overlayWidth = 1000, + overlayHeight = 2000, + ) { closedBeforeCallback = closed } analyzer.analyze(imageProxy) @@ -83,10 +83,12 @@ internal class CropToTargetOverlayAnalyzerTest { @Test fun `Correctly crops when camera resolution is smaller than preview in landscape`() { - // Target is a square 600x600px with 200px from top bounds - setupScreenSize(2000, 1000) - every { targetOverlay.circleRect } returns RectF(700f, 200f, 1300f, 800f) setupImageSize(1000, 1000) + analyzer = CropToTargetOverlayAnalyzer( + previewRect = RectF(700f, 200f, 1300f, 800f), + overlayWidth = 2000, + overlayHeight = 1000, + ) { capturedBitmap = it } analyzer.analyze(imageProxy) @@ -97,10 +99,12 @@ internal class CropToTargetOverlayAnalyzerTest { @Test fun `Correctly crops when camera resolution is larger than preview in portrait`() { - // Target is a square 600x600px with 200px from top bounds - setupScreenSize(1000, 2000) - every { targetOverlay.circleRect } returns RectF(200f, 200f, 800f, 800f) setupImageSize(2000, 2000) + analyzer = CropToTargetOverlayAnalyzer( + previewRect = RectF(200f, 200f, 800f, 800f), + overlayWidth = 1000, + overlayHeight = 2000, + ) { capturedBitmap = it } analyzer.analyze(imageProxy) @@ -111,10 +115,12 @@ internal class CropToTargetOverlayAnalyzerTest { @Test fun `Correctly crops when camera resolution is larger than preview in landscape`() { - // Target is a square 600x600px with 200px from top bounds - setupScreenSize(2000, 1000) - every { targetOverlay.circleRect } returns RectF(700f, 200f, 1300f, 800f) setupImageSize(2000, 2000) + analyzer = CropToTargetOverlayAnalyzer( + previewRect = RectF(700f, 200f, 1300f, 800f), + overlayWidth = 2000, + overlayHeight = 1000, + ) { capturedBitmap = it } analyzer.analyze(imageProxy) @@ -123,14 +129,6 @@ internal class CropToTargetOverlayAnalyzerTest { assertThat(capturedBitmap?.height).isEqualTo(600) } - private fun setupScreenSize( - width: Int, - height: Int, - ) { - every { targetOverlay.width } returns width - every { targetOverlay.height } returns height - } - private fun setupImageSize( width: Int, height: Int, From 964a633fc692c4025174af55069d9ecbc1d5564d Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Fri, 22 May 2026 22:11:02 +0100 Subject: [PATCH 12/14] Add unit tests for FragmentViewBindingDelegate --- .../FragmentViewBindingDelegateTest.kt | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 infra/ui-base/src/test/java/com/simprints/infra/uibase/viewbinding/FragmentViewBindingDelegateTest.kt diff --git a/infra/ui-base/src/test/java/com/simprints/infra/uibase/viewbinding/FragmentViewBindingDelegateTest.kt b/infra/ui-base/src/test/java/com/simprints/infra/uibase/viewbinding/FragmentViewBindingDelegateTest.kt new file mode 100644 index 0000000000..bb58724fd1 --- /dev/null +++ b/infra/ui-base/src/test/java/com/simprints/infra/uibase/viewbinding/FragmentViewBindingDelegateTest.kt @@ -0,0 +1,130 @@ +package com.simprints.infra.uibase.viewbinding + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.test.ext.junit.runners.* +import androidx.viewbinding.ViewBinding +import com.google.common.truth.Truth.* +import io.mockk.* +import io.mockk.impl.annotations.MockK +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.reflect.KProperty + +@RunWith(AndroidJUnit4::class) +internal class FragmentViewBindingDelegateTest { + @MockK + lateinit var fragment: Fragment + + @MockK + lateinit var fragmentLifecycle: Lifecycle + + @MockK + lateinit var viewLifecycleOwner: LifecycleOwner + + @MockK + lateinit var viewLifecycle: Lifecycle + + @MockK + lateinit var liveData: LiveData + + @MockK + lateinit var view: View + + @MockK + lateinit var binding: ViewBinding + + @MockK + lateinit var property: KProperty<*> + + private lateinit var delegate: FragmentViewBindingDelegate + private val fragmentObserverSlot = slot() + private val liveDataObserverSlot = slot>() + + private var factoryCallCount = 0 + + @Before + fun setUp() { + MockKAnnotations.init(this) + factoryCallCount = 0 + + every { fragment.lifecycle } returns fragmentLifecycle + every { fragment.viewLifecycleOwnerLiveData } returns liveData + every { fragment.viewLifecycleOwner } returns viewLifecycleOwner + every { fragment.requireView() } returns view + every { viewLifecycleOwner.lifecycle } returns viewLifecycle + + justRun { fragmentLifecycle.addObserver(capture(fragmentObserverSlot)) } + justRun { liveData.observeForever(capture(liveDataObserverSlot)) } + justRun { liveData.removeObserver(any()) } + + val factory: (View) -> ViewBinding = { + factoryCallCount++ + binding + } + + delegate = FragmentViewBindingDelegate(fragment, factory) + } + + @Test + fun `getValue creates and caches binding when view is initialized`() { + every { viewLifecycle.currentState } returns Lifecycle.State.INITIALIZED + + val result1 = delegate.getValue(fragment, property) + val result2 = delegate.getValue(fragment, property) + + assertThat(result1).isEqualTo(binding) + assertThat(result2).isEqualTo(binding) + assertThat(factoryCallCount).isEqualTo(1) + } + + @Test + fun `getValue throws IllegalStateException when view is destroyed`() { + every { viewLifecycle.currentState } returns Lifecycle.State.DESTROYED + + val exception = assertThrows(IllegalStateException::class.java) { + delegate.getValue(fragment, property) + } + + assertThat(exception) + .hasMessageThat() + .isEqualTo("Should not attempt to get bindings when Fragment views are destroyed.") + + // Assert: Ensure it didn't try to instantiate a dead view + assertThat(factoryCallCount).isEqualTo(0) + } + + @Test + fun `binding is safely cleared when viewLifecycleOwnerLiveData emits null`() { + every { viewLifecycle.currentState } returns Lifecycle.State.CREATED + + delegate.getValue(fragment, property) + assertThat(factoryCallCount).isEqualTo(1) + + fragmentObserverSlot.captured.onCreate(fragment) + + liveDataObserverSlot.captured.onChanged(null) + + delegate.getValue(fragment, property) + + assertThat(factoryCallCount).isEqualTo(2) + } + + @Test + fun `observers are correctly registered and unregistered during fragment lifecycle`() { + val observer = fragmentObserverSlot.captured + + observer.onCreate(fragment) + verify(exactly = 1) { liveData.observeForever(any()) } + + observer.onDestroy(fragment) + verify(exactly = 1) { liveData.removeObserver(any()) } + } +} From 040f2088af11cd03bae2b5ed687bd8ea92494a0d Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 25 May 2026 15:00:32 +0300 Subject: [PATCH 13/14] MS-1441 Force an update of selected modules list after sync completion --- .../ObserveConfigurationChangesUseCase.kt | 19 +++- .../ObserveConfigurationChangesUseCaseTest.kt | 92 ++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCase.kt index 2feeceae06..951a052a74 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCase.kt @@ -9,19 +9,27 @@ import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecordQuery +import com.simprints.infra.sync.SyncOrchestrator +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import javax.inject.Inject internal class ObserveConfigurationChangesUseCase @Inject constructor( private val configRepository: ConfigRepository, private val tokenizationProcessor: TokenizationProcessor, private val enrolmentRecordRepository: EnrolmentRecordRepository, + private val syncOrchestrator: SyncOrchestrator, ) { operator fun invoke() = combine( configRepository.observeIsProjectRefreshing(), configRepository.observeProjectConfiguration(), configRepository.observeDeviceConfiguration(), - ) { isRefreshing, projectConfig, deviceConfig -> + syncCompletedSignalFlow(), + ) { isRefreshing, projectConfig, deviceConfig, _ -> val project = configRepository.getProject() val moduleCounts = if (project != null) { @@ -52,6 +60,15 @@ internal class ObserveConfigurationChangesUseCase @Inject constructor( projectConfig = projectConfig, ) } + + // Force update of module list when sync completes + private fun syncCompletedSignalFlow(): Flow = syncOrchestrator + .observeSyncState() + .map { it.eventSyncState.isSyncCompleted() } + .distinctUntilChanged() + .filter { it } + .map { Unit } + .onStart { emit(Unit) } } internal data class ConfigurationState( diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCaseTest.kt index 7b162d88c5..25c8c2af8d 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCaseTest.kt @@ -11,11 +11,21 @@ import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository +import com.simprints.infra.eventsync.status.models.EventSyncState +import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncStatus import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -39,16 +49,24 @@ class ObserveConfigurationChangesUseCaseTest { @MockK private lateinit var deviceConfiguration: DeviceConfiguration + @MockK + private lateinit var syncOrchestrator: SyncOrchestrator + + private lateinit var syncStateFlow: MutableStateFlow + private lateinit var useCase: ObserveConfigurationChangesUseCase @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + syncStateFlow = MutableStateFlow(createSyncStatus(isSyncCompleted = false)) + every { syncOrchestrator.observeSyncState() } returns syncStateFlow useCase = ObserveConfigurationChangesUseCase( configRepository = configRepository, tokenizationProcessor = tokenizationProcessor, enrolmentRecordRepository = enrolmentRepository, + syncOrchestrator = syncOrchestrator, ) } @@ -72,7 +90,7 @@ class ObserveConfigurationChangesUseCaseTest { every { configRepository.observeProjectConfiguration() } returns flowOf(projectConfiguration) every { configRepository.observeDeviceConfiguration() } returns flowOf(deviceConfiguration) - val result = useCase().toList() + val result = useCase().take(2).toList() assertThat(result.first().isRefreshing).isTrue() assertThat(result.last().isRefreshing).isFalse() @@ -103,4 +121,76 @@ class ObserveConfigurationChangesUseCaseTest { ModuleCount("moduleUntokenized", 2), ) } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `re-emits configuration state when sync completes`() = runTest { + every { syncOrchestrator.observeSyncState() } returns syncStateFlow + + coEvery { configRepository.getProject() } returns project + every { project.id } returns "projectId" + every { project.state } returns ProjectState.RUNNING + every { deviceConfiguration.selectedModules } returns listOf("moduleRaw".asTokenizableRaw()) + coEvery { enrolmentRepository.count(any(), any()) } returnsMany listOf(1, 2) + + every { configRepository.observeIsProjectRefreshing() } returns flowOf(false) + every { configRepository.observeProjectConfiguration() } returns flowOf(projectConfiguration) + every { configRepository.observeDeviceConfiguration() } returns flowOf(deviceConfiguration) + + val emissions = mutableListOf() + val job = backgroundScope.launch { + useCase().take(2).toList(emissions) + } + + advanceUntilIdle() + syncStateFlow.value = createSyncStatus(isSyncCompleted = true) + advanceUntilIdle() + job.join() + + assertThat(emissions).hasSize(2) + assertThat(emissions[0].selectedModules).containsExactly(ModuleCount("moduleRaw", 1)) + assertThat(emissions[1].selectedModules).containsExactly(ModuleCount("moduleRaw", 2)) + coVerify(exactly = 2) { enrolmentRepository.count(any(), any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `does not re-emit when sync completed state is repeated`() = runTest { + every { syncOrchestrator.observeSyncState() } returns syncStateFlow + + coEvery { configRepository.getProject() } returns project + every { project.id } returns "projectId" + every { project.state } returns ProjectState.RUNNING + every { deviceConfiguration.selectedModules } returns listOf("moduleRaw".asTokenizableRaw()) + coEvery { enrolmentRepository.count(any(), any()) } returnsMany listOf(1, 2, 3) + + every { configRepository.observeIsProjectRefreshing() } returns flowOf(false) + every { configRepository.observeProjectConfiguration() } returns flowOf(projectConfiguration) + every { configRepository.observeDeviceConfiguration() } returns flowOf(deviceConfiguration) + + val emissions = mutableListOf() + val job = launch { + useCase().toList(emissions) + } + + advanceUntilIdle() + syncStateFlow.value = createSyncStatus(isSyncCompleted = true) + advanceUntilIdle() + syncStateFlow.value = createSyncStatus(isSyncCompleted = true) + advanceUntilIdle() + job.cancelAndJoin() + + assertThat(emissions).hasSize(2) + assertThat(emissions.map { it.selectedModules.single().count }).containsExactly(1, 2).inOrder() + coVerify(exactly = 2) { enrolmentRepository.count(any(), any()) } + } + + private fun createSyncStatus(isSyncCompleted: Boolean): SyncStatus { + val eventSyncState = mockk() + every { eventSyncState.isSyncCompleted() } returns isSyncCompleted + return SyncStatus( + eventSyncState = eventSyncState, + imageSyncStatus = mockk(relaxed = true), + ) + } } From a9e6a63afb01ec7f2f70830ad5332334953ac240 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 25 May 2026 15:49:20 +0300 Subject: [PATCH 14/14] MS-1435 Add a generic MFID skip reason string set --- .../skip/ExternalCredentialSkipFragment.kt | 38 +++++++++++-------- .../resources/src/main/res/values/strings.xml | 10 +++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt index b1bf2bc583..2b12c177c2 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt @@ -41,21 +41,29 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede } private fun initViews(credentialTypes: List) = with(binding) { - mapOf( - title to IDR.string.mfid_skip_title, - skipReasonHasNumberNoId to IDR.string.mfid_skip_reason_has_number_no_id, - skipReasonDoesNotHaveDocument to IDR.string.mfid_skip_reason_does_not_have, - skipReasonDidNotBring to IDR.string.mfid_skip_reason_did_not_bring, - skipReasonIncorrect to IDR.string.mfid_skip_reason_incorrect, - skipReasonDoesNotWantToProvide to IDR.string.mfid_skip_reason_does_not_want_to_provide, - skipReasonDamaged to IDR.string.mfid_skip_reason_damaged, - skipReasonUnableToScan to IDR.string.mfid_skip_reason_unable_to_scan, - ).forEach { (textView, stringRes) -> - val credentialText = when (credentialTypes.size) { - 1 -> resources.getCredentialTypeString(credentialTypes.first()) - else -> getString(IDR.string.mfid_type_any_document) - } - textView.text = getString(stringRes, credentialText) + if (credentialTypes.size == 1) { + val credentialText = resources.getCredentialTypeString(credentialTypes.first()) + mapOf( + title to IDR.string.mfid_skip_title_generic, + skipReasonHasNumberNoId to IDR.string.mfid_skip_reason_generic_has_number_no_id, + skipReasonDoesNotHaveDocument to IDR.string.mfid_skip_reason_generic_does_not_have, + skipReasonDidNotBring to IDR.string.mfid_skip_reason_generic_did_not_bring, + skipReasonIncorrect to IDR.string.mfid_skip_reason_generic_incorrect, + skipReasonDoesNotWantToProvide to IDR.string.mfid_skip_reason_generic_does_not_want_to_provide, + skipReasonDamaged to IDR.string.mfid_skip_reason_generic_damaged, + skipReasonUnableToScan to IDR.string.mfid_skip_reason_generic_unable_to_scan, + ).forEach { (textView, stringRes) -> textView.text = getString(stringRes, credentialText) } + } else { + mapOf( + title to IDR.string.mfid_skip_title, + skipReasonHasNumberNoId to IDR.string.mfid_skip_reason_has_number_no_id, + skipReasonDoesNotHaveDocument to IDR.string.mfid_skip_reason_does_not_have, + skipReasonDidNotBring to IDR.string.mfid_skip_reason_did_not_bring, + skipReasonIncorrect to IDR.string.mfid_skip_reason_incorrect, + skipReasonDoesNotWantToProvide to IDR.string.mfid_skip_reason_does_not_want_to_provide, + skipReasonDamaged to IDR.string.mfid_skip_reason_damaged, + skipReasonUnableToScan to IDR.string.mfid_skip_reason_unable_to_scan, + ).forEach { (textView, stringRes) -> textView.text = getString(stringRes) } } } diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index 91f7432a62..79c859d328 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -542,6 +542,16 @@ Other Reason Please provide a reason for skipping + Why did you skip the document scan? + Has number, no document (Booklet) + Does not have a document + Did not bring a document + Brought incorrect document + Does not want to provide a document + Document damaged or unreadable + Unable to scan document + Other + Reason Skip Scanning Go Back