From 6442df5e88b6d6decc7c108e3657a65dbbf3e49b Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Mon, 16 Jun 2025 11:18:58 +0300 Subject: [PATCH 1/8] [MS-1028] Fix pagination logic by adjusting pageSize calculation in RoomEnrolmentRecordLocalDataSource --- .../local/RoomEnrolmentRecordLocalDataSource.kt | 2 +- .../local/RoomEnrolmentRecordLocalDataSourceTest.kt | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt index c7f9972abc..9fedec3ef1 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt @@ -159,7 +159,7 @@ internal class RoomEnrolmentRecordLocalDataSource @Inject constructor( } val identities = loadBiometricIdentities( query = query.copy(afterSubjectId = afterSubjectId), // update query with the last seen subject ID - pageSize = range.last - range.first, + pageSize = range.last - range.first + 1, format = format, createIdentity = createIdentity, onCandidateLoaded = onCandidateLoaded, diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt index 4aebfd57e7..52cc6e0285 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt @@ -890,8 +890,8 @@ class RoomEnrolmentRecordLocalDataSourceTest { .loadFingerprintIdentities( query = baseQuery, ranges = listOf( - 0..1, - 2..3, + 0..0, + 1..1, ), project = project, dataSource = Simprints, @@ -904,7 +904,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { .loadFingerprintIdentities( query = baseQuery, ranges = listOf( - 0..2, + 0..1, ), project = project, dataSource = Simprints, @@ -1078,8 +1078,8 @@ class RoomEnrolmentRecordLocalDataSourceTest { .loadFaceIdentities( query = baseQuery, ranges = listOf( - 0..1, - 2..3, + 0..0, + 1..1, ), project = project, dataSource = Simprints, @@ -1092,7 +1092,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { .loadFaceIdentities( query = baseQuery, ranges = listOf( - 0..2, + 0..1, ), project = project, dataSource = Simprints, From 75df806870d697d0ea4b6c18ab1e67df29ee98da Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Mon, 16 Jun 2025 23:04:54 +0300 Subject: [PATCH 2/8] [MS-1029] Retrieve Room DB encryption status directly from BuildConfig --- .../RoomEnrolmentRecordLocalDataSource.kt | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt index 65687bf51e..bd47a046aa 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt @@ -1,7 +1,5 @@ package com.simprints.infra.enrolment.records.repository.local -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException import androidx.room.withTransaction import com.simprints.core.DispatcherIO import com.simprints.core.domain.face.FaceSample @@ -17,6 +15,7 @@ import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAct import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.enrolment.records.repository.local.models.toDomain import com.simprints.infra.enrolment.records.repository.local.models.toRoomDb +import com.simprints.infra.enrolment.records.room.store.BuildConfig.DB_ENCRYPTION import com.simprints.infra.enrolment.records.room.store.SubjectDao import com.simprints.infra.enrolment.records.room.store.SubjectsDatabase import com.simprints.infra.enrolment.records.room.store.SubjectsDatabaseFactory @@ -229,7 +228,7 @@ internal class RoomEnrolmentRecordLocalDataSource @Inject constructor( val dbVersion = database.openHelper.readableDatabase.version val dbPath = database.openHelper.readableDatabase.path val dbSize = getTotalRoomDbSizeBytes(dbPath!!) - val isDBEncrypted = isDBEncrypted(dbPath) + val isDBEncrypted = DB_ENCRYPTION val subjectCount = subjectDao.countSubjects(queryBuilder.buildCountQuery(SubjectQuery())) "Room DB Info:\n" + "Database Name: ${database.openHelper.databaseName}\n" + @@ -241,19 +240,6 @@ internal class RoomEnrolmentRecordLocalDataSource @Inject constructor( } } - private fun isDBEncrypted(fullDbPath: String): Boolean = try { - SQLiteDatabase.openDatabase(fullDbPath, null, SQLiteDatabase.OPEN_READONLY).use { db -> - db.rawQuery("PRAGMA schema_version;", null).use { cursor -> - cursor.moveToFirst() - // If we can read the schema version, it's not encrypted - false - } - } - } catch (_: SQLiteException) { - // Exception likely means the DB is encrypted - true - } - private fun getTotalRoomDbSizeBytes(fullDbPath: String): Long { val baseFile = File(fullDbPath) val walFile = File("$fullDbPath-wal") From 62beff32801c216da672a039b178eb774de25d81 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 17 Jun 2025 11:12:44 +0300 Subject: [PATCH 3/8] [MS-1030] Ensure thread-safe initialization of Realm instance in getRealm method --- .../enrolment/records/realm/store/RealmWrapperImpl.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/infra/enrolment-records/realm-store/src/main/java/com/simprints/infra/enrolment/records/realm/store/RealmWrapperImpl.kt b/infra/enrolment-records/realm-store/src/main/java/com/simprints/infra/enrolment/records/realm/store/RealmWrapperImpl.kt index 35307646e7..6d0236c1b5 100644 --- a/infra/enrolment-records/realm-store/src/main/java/com/simprints/infra/enrolment/records/realm/store/RealmWrapperImpl.kt +++ b/infra/enrolment-records/realm-store/src/main/java/com/simprints/infra/enrolment/records/realm/store/RealmWrapperImpl.kt @@ -18,6 +18,8 @@ import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm import io.realm.kotlin.RealmConfiguration import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -34,8 +36,9 @@ class RealmWrapperImpl @Inject constructor( // https://www.mongodb.com/docs/realm/sdk/kotlin/realm-database/frozen-arch/#thread-safe-realms private lateinit var realm: Realm private lateinit var config: RealmConfiguration + private val mutex = Mutex() - private fun getRealm(): Realm { + private suspend fun getRealm(): Realm { if (!this::realm.isInitialized) { config = createAndSaveRealmConfig() realm = createRealm() @@ -43,9 +46,9 @@ class RealmWrapperImpl @Inject constructor( return realm } - private fun createRealm(): Realm { + private suspend fun createRealm(): Realm = mutex.withLock { Simber.d("[RealmWrapperImpl] getting new realm instance", tag = REALM_DB) - return try { + try { try { Realm.open(config) } catch (ex: IllegalStateException) { From 0c909cad4dde1e043269a97a9de569be147bb745 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Thu, 19 Jun 2025 15:41:35 +0300 Subject: [PATCH 4/8] [MS-1032] Schedule Realm to Room migration on project config refresh --- .../dashboard/tools/di/FakeCoreModule.kt | 8 +++++ .../infra/config/sync/ConfigManager.kt | 7 ++-- .../infra/config/sync/ConfigManagerTest.kt | 33 +++++++++++++++---- .../java/com/simprints/core/CoreModule.kt | 6 ++++ .../com/simprints/infra/sync/SyncModule.kt | 15 +-------- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt index 6e1578218c..10fde14e01 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt @@ -1,5 +1,7 @@ package com.simprints.feature.dashboard.tools.di +import android.content.Context +import androidx.work.WorkManager import com.simprints.core.AppScope import com.simprints.core.AvailableProcessors import com.simprints.core.CoreModule @@ -17,6 +19,7 @@ import com.simprints.core.tools.utils.StringTokenizer import com.simprints.testtools.unit.EncodingUtilsImplForTests import dagger.Module import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import io.mockk.mockk @@ -88,4 +91,9 @@ object FakeCoreModule { @Provides @Singleton fun provideEncodingUtils(): EncodingUtils = EncodingUtilsImplForTests + + @Provides + fun provideWorkManager( + @ApplicationContext context: Context, + ): WorkManager = WorkManager.getInstance(context) } diff --git a/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt b/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt index 7d257aa7e3..52115149c3 100644 --- a/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt +++ b/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt @@ -8,6 +8,7 @@ import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.ProjectWithConfig import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository +import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationScheduler import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onStart import javax.inject.Inject @@ -16,15 +17,17 @@ class ConfigManager @Inject constructor( private val configRepository: ConfigRepository, private val enrolmentRecordRepository: EnrolmentRecordRepository, private val configSyncCache: ConfigSyncCache, + private val realmToRoomMigrationScheduler: RealmToRoomMigrationScheduler, ) { suspend fun refreshProject(projectId: String): ProjectWithConfig = configRepository.refreshProject(projectId).also { enrolmentRecordRepository.tokenizeExistingRecords(it.project) configSyncCache.saveUpdateTime() + realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() } suspend fun getProject(projectId: String): Project = try { configRepository.getProject() - } catch (e: NoSuchElementException) { + } catch (_: NoSuchElementException) { refreshProject(projectId).project } @@ -35,7 +38,7 @@ class ConfigManager @Inject constructor( try { // Try to refresh it with logged in projectId (if any) refreshProject(configRepository.getProject().id).configuration - } catch (e: Exception) { + } catch (_: Exception) { // If not logged in the above will fail. However we still depend on the 'default' // configuration to create the session when login is attempted. Possibly in other // places, too. diff --git a/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt b/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt index 92ffbe74fa..118bc34f2a 100644 --- a/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt +++ b/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt @@ -1,16 +1,14 @@ package com.simprints.infra.config.sync -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.* import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.DeviceConfiguration import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.ProjectWithConfig import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository -import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every +import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationScheduler +import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest @@ -43,6 +41,9 @@ class ConfigManagerTest { @MockK private lateinit var deviceConfiguration: DeviceConfiguration + @MockK + private lateinit var realmToRoomMigrationScheduler: RealmToRoomMigrationScheduler + @MockK private lateinit var project: Project @@ -53,6 +54,7 @@ class ConfigManagerTest { configRepository = configRepository, enrolmentRecordRepository = enrolmentRecordRepository, configSyncCache = configSyncCache, + realmToRoomMigrationScheduler = realmToRoomMigrationScheduler, ) } @@ -64,6 +66,7 @@ class ConfigManagerTest { assertThat(refreshedProject).isEqualTo(projectWithConfig) coVerify { configSyncCache.saveUpdateTime() } + coVerify { realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() } } @Test @@ -74,6 +77,14 @@ class ConfigManagerTest { assertThat(gottenProject).isEqualTo(project) } + @Test + fun `getProject should call the refresh method when cannot get from local`() = runTest { + coEvery { configRepository.getProject() } throws NoSuchElementException() + + configManager.getProject(PROJECT_ID) + coVerify(exactly = 1) { configRepository.refreshProject(PROJECT_ID) } + } + @Test fun `getProjectConfiguration should call the correct method`() = runTest { coEvery { configRepository.getProjectConfiguration() } returns projectConfiguration @@ -83,6 +94,16 @@ class ConfigManagerTest { assertThat(gottenProjectConfiguration).isEqualTo(projectConfiguration) } + @Test + fun `getProjectConfiguration return default config if not logged in`() = runTest { + every { projectConfiguration.projectId } returns "" + coEvery { configRepository.getProjectConfiguration() } returns projectConfiguration + coEvery { configRepository.refreshProject(any()) } throws Exception() + + val gottenProjectConfiguration = configManager.getProjectConfiguration() + assertThat(gottenProjectConfiguration).isEqualTo(projectConfiguration) + } + @Test fun `refreshProjectConfiguration should call the correct method`() = runTest { coEvery { configRepository.refreshProject(PROJECT_ID) } returns projectWithConfig @@ -120,7 +141,7 @@ class ConfigManagerTest { configManager.getPrivacyNotice(PROJECT_ID, LANGUAGE) coVerify(exactly = 1) { configRepository.getPrivacyNotice(PROJECT_ID, LANGUAGE) } } - + @Test fun `watchProjectConfiguration should emit values from the local data source`() = runTest { val config1 = projectConfiguration.copy(projectId = "project1") diff --git a/infra/core/src/main/java/com/simprints/core/CoreModule.kt b/infra/core/src/main/java/com/simprints/core/CoreModule.kt index ffbae4b1c7..d2dc20408f 100644 --- a/infra/core/src/main/java/com/simprints/core/CoreModule.kt +++ b/infra/core/src/main/java/com/simprints/core/CoreModule.kt @@ -1,6 +1,7 @@ package com.simprints.core import android.content.Context +import androidx.work.WorkManager import com.lyft.kronos.AndroidClockFactory import com.simprints.core.tools.exceptions.AppCoroutineExceptionHandler import com.simprints.core.tools.extentions.deviceHardwareId @@ -134,6 +135,11 @@ object CoreModule { ): CoroutineScope = CoroutineScope( SupervisorJob() + dispatcherMain + AppCoroutineExceptionHandler(), ) + + @Provides + fun provideWorkManager( + @ApplicationContext context: Context, + ): WorkManager = WorkManager.getInstance(context) } @Qualifier diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncModule.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncModule.kt index 6c689c914c..48cf24d717 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncModule.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncModule.kt @@ -1,26 +1,13 @@ package com.simprints.infra.sync -import android.content.Context -import androidx.work.WorkManager import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -object SyncModule { - @Provides - fun provideWorkManager( - @ApplicationContext context: Context, - ): WorkManager = WorkManager.getInstance(context) -} - -@Module -@InstallIn(SingletonComponent::class) -abstract class SyncOrchestratorModule { +abstract class SyncModule { @Binds internal abstract fun provideSyncOrchestrator(syncOrchestratorImpl: SyncOrchestratorImpl): SyncOrchestrator } From 085af0fc7d808cd5949b8dab66893af9760b130a Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Sat, 21 Jun 2025 18:13:36 +0300 Subject: [PATCH 5/8] Clear RealmToRoom migration flags on logout --- .../feature/dashboard/logout/usecase/LogoutUseCase.kt | 4 ++++ .../dashboard/logout/usecase/LogoutUseCaseTest.kt | 6 ++++++ .../local/migration/RealmToRoomMigrationFlagsStore.kt | 8 ++++++++ .../migration/RealmToRoomMigrationFlagsStoreTest.kt | 10 ++++++++++ 4 files changed, 28 insertions(+) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt index 7b197f5b35..3cd1fde411 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt @@ -1,6 +1,7 @@ package com.simprints.feature.dashboard.logout.usecase import com.simprints.infra.authlogic.AuthManager +import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore import com.simprints.infra.sync.SyncOrchestrator import kotlinx.coroutines.runBlocking import javax.inject.Inject @@ -8,6 +9,7 @@ import javax.inject.Inject internal class LogoutUseCase @Inject constructor( private val syncOrchestrator: SyncOrchestrator, private val authManager: AuthManager, + private val flagsStore: RealmToRoomMigrationFlagsStore, ) { operator fun invoke() = runBlocking { // Cancel all background sync @@ -15,5 +17,7 @@ internal class LogoutUseCase @Inject constructor( syncOrchestrator.deleteEventSyncInfo() // sign out the user authManager.signOut() + // Reset migration flags + flagsStore.clearMigrationFlags() } } diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt index 6270445f52..960cd5fc79 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt @@ -2,6 +2,7 @@ package com.simprints.feature.dashboard.logout.usecase import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.simprints.infra.authlogic.AuthManager +import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations @@ -25,6 +26,9 @@ class LogoutUseCaseTest { @MockK private lateinit var authManager: AuthManager + @MockK + private lateinit var flagsStore: RealmToRoomMigrationFlagsStore + private lateinit var useCase: LogoutUseCase @Before @@ -34,6 +38,7 @@ class LogoutUseCaseTest { useCase = LogoutUseCase( syncOrchestrator = syncOrchestrator, authManager = authManager, + flagsStore = flagsStore, ) } @@ -45,6 +50,7 @@ class LogoutUseCaseTest { syncOrchestrator.cancelBackgroundWork() syncOrchestrator.deleteEventSyncInfo() authManager.signOut() + flagsStore.clearMigrationFlags() } } } diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationFlagsStore.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationFlagsStore.kt index 80d5b9d65c..0e8ba5b04d 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationFlagsStore.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationFlagsStore.kt @@ -90,4 +90,12 @@ class RealmToRoomMigrationFlagsStore @Inject constructor( $KEY_DOWN_SYNC_STATUS: $downSync """.trimIndent() } + + /** + * Clears all migration-related keys from the store. + */ + fun clearMigrationFlags() { + prefs.edit { clear() } + Simber.i("[RealmToRoomMigrationFlagsStore] Migration flags cleared", tag = REALM_DB_MIGRATION) + } } diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationFlagsStoreTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationFlagsStoreTest.kt index 45642c6747..145cefe169 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationFlagsStoreTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationFlagsStoreTest.kt @@ -300,4 +300,14 @@ class RealmToRoomMigrationFlagsStoreTest { verify { editor.putBoolean(RealmToRoomMigrationFlagsStore.KEY_DOWN_SYNC_STATUS, false) } verify { editor.apply() } } + + @Test + fun `clearMigrationFlags should remove all migration-related keys`() { + // Given + every { editor.clear() } returns editor + // When + store.clearMigrationFlags() + // Then + verify { editor.clear() } + } } From c9edd8fe132cb90dedf1b7b7d849dee50275484f Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Mon, 23 Jun 2025 06:28:36 +0300 Subject: [PATCH 6/8] [MS-1033] Ensure DAO operations are performed on IO dispatcher and add test for deleteAll The `delete` and `deleteAll` functions in `RoomEnrolmentRecordLocalDataSource` are updated to explicitly use the `dispatcherIO` context. --- .../local/RoomEnrolmentRecordLocalDataSource.kt | 4 ++-- .../RoomEnrolmentRecordLocalDataSourceTest.kt | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt index bd47a046aa..61aed68169 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt @@ -193,7 +193,7 @@ internal class RoomEnrolmentRecordLocalDataSource @Inject constructor( } } - override suspend fun delete(queries: List) { + override suspend fun delete(queries: List): Unit = withContext(dispatcherIO) { Simber.i("[delete] Deleting subjects with queries: $queries", tag = ROOM_RECORDS_DB) database.withTransaction { queries.forEach { query -> @@ -202,7 +202,7 @@ internal class RoomEnrolmentRecordLocalDataSource @Inject constructor( } } - override suspend fun deleteAll() { + override suspend fun deleteAll(): Unit = withContext(dispatcherIO) { Simber.i("[deleteAll] Deleting all subjects.", tag = ROOM_RECORDS_DB) subjectDao.deleteSubjects(queryBuilder.buildDeleteQuery(SubjectQuery())) } diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt index c95067b6d0..c916779534 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt @@ -1544,4 +1544,19 @@ class RoomEnrolmentRecordLocalDataSourceTest { // Then: Exception expected } + + @Test + fun `delete all - should delete all subjects`() = runTest { + // Given + setupInitialData() + val initialCount = dataSource.count() + + // When + dataSource.deleteAll() + + // Then + val finalCount = dataSource.count() + assertThat(finalCount).isEqualTo(0) + assertThat(initialCount).isGreaterThan(0) + } } From 9f61c05e09d06903142c7499571ec0205f23c7c9 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 26 Jun 2025 10:04:30 +0300 Subject: [PATCH 7/8] MS-1037 Also count enrolment v4 for record upload counter in sync info --- .../dashboard/main/sync/SyncViewModel.kt | 2 +- .../settings/about/AboutViewModel.kt | 2 +- .../settings/syncinfo/SyncInfoViewModel.kt | 11 +++---- .../dashboard/main/sync/SyncViewModelTest.kt | 3 +- .../settings/about/AboutViewModelTest.kt | 4 +-- .../syncinfo/SyncInfoViewModelTest.kt | 29 ++++++++++--------- .../infra/eventsync/EventSyncManager.kt | 4 ++- .../infra/eventsync/EventSyncManagerImpl.kt | 7 ++++- .../infra/eventsync/EventSyncManagerTest.kt | 14 +++++++-- .../usecase/HandleProjectStateUseCase.kt | 2 +- .../usecase/HandleProjectStateUseCaseTest.kt | 8 ++--- 11 files changed, 50 insertions(+), 36 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt index a2e49ab11e..93e207d2ff 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt @@ -187,7 +187,7 @@ internal class SyncViewModel @Inject constructor( _syncToBFSIDAllowed.postValue(configuration.canSyncDataToSimprints() || configuration.isEventDownSyncAllowed()) } eventSyncManager - .countEventsToUpload(EventType.ENROLMENT_V2) + .countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)) .collect { upSyncCountLiveData.postValue(it) } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutViewModel.kt index 5d2b227c82..63d43cb55c 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutViewModel.kt @@ -78,7 +78,7 @@ internal class AboutViewModel @Inject constructor( } } - private suspend fun hasEventsToUpload(): Boolean = eventSyncManager.countEventsToUpload(type = null).first() > 0 + private suspend fun hasEventsToUpload(): Boolean = eventSyncManager.countEventsToUpload().first() > 0 private suspend fun canSyncDataToSimprints(): Boolean = configManager.getProjectConfiguration().canSyncDataToSimprints() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt index 59d238cea5..1b2325ea5d 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt @@ -35,7 +35,6 @@ import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import javax.inject.Inject @@ -123,11 +122,11 @@ internal class SyncInfoViewModel @Inject constructor( _isReloginRequired.addSource(lastSyncState) { lastSyncStateValue -> _isReloginRequired.postValue(lastSyncStateValue.isSyncFailedBecauseReloginRequired()) } + viewModelScope.launch { getRecordsToUpSync() } } fun refreshInformation() { _recordsInLocal.postValue(null) - _recordsToUpSync.postValue(null) _recordsToDownSync.postValue(null) _imagesToUpload.postValue(null) _moduleCounts.postValue(listOf()) @@ -179,7 +178,6 @@ internal class SyncInfoViewModel @Inject constructor( awaitAll( async { _configuration.postValue(configManager.getProjectConfiguration()) }, async { _recordsInLocal.postValue(getRecordsInLocal(projectId)) }, - async { _recordsToUpSync.postValue(getRecordsToUpSync()) }, async { _recordsToDownSync.postValue(fetchRecordsToCreateAndDeleteCount()) }, async { _imagesToUpload.postValue(imageRepository.getNumberOfImagesToUpload(projectId)) }, async { _moduleCounts.postValue(getModuleCounts(projectId)) }, @@ -207,10 +205,9 @@ internal class SyncInfoViewModel @Inject constructor( private suspend fun getRecordsInLocal(projectId: String): Int = enrolmentRecordRepository.count(SubjectQuery(projectId = projectId)) - private suspend fun getRecordsToUpSync(): Int = eventSyncManager - .countEventsToUpload(EventType.ENROLMENT_V2) - .firstOrNull() - ?: 0 + private suspend fun getRecordsToUpSync() = eventSyncManager + .countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)) + .collect { _recordsToUpSync.postValue(it) } private suspend fun fetchRecordsToCreateAndDeleteCount(): DownSyncCounts = if (configManager.getProjectConfiguration().isEventDownSyncAllowed()) { diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt index b5610d217c..e143e70416 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt @@ -28,6 +28,7 @@ import com.simprints.infra.config.store.models.UpSynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.SimprintsUpSynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ALL import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.events.event.domain.models.EventType import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.status.models.EventSyncWorkerState @@ -234,7 +235,7 @@ internal class SyncViewModelTest { @Test fun `should post a SyncPendingUpload card state if there are records to upload`() { coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - coEvery { eventSyncManager.countEventsToUpload(any()) }.returns(flowOf(2)) + coEvery { eventSyncManager.countEventsToUpload(any>()) }.returns(flowOf(2)) isConnected.value = true syncState.value = EventSyncState( diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/about/AboutViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/about/AboutViewModelTest.kt index 6b26a357ae..8fd01ffb9b 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/about/AboutViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/about/AboutViewModelTest.kt @@ -203,9 +203,7 @@ class AboutViewModelTest { true -> 1 false -> 0 } - coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf( - countEventsToUpload, - ) + coEvery { eventSyncManager.countEventsToUpload() } returns flowOf(countEventsToUpload) coEvery { configManager.getProjectConfiguration() } returns buildProjectConfigurationMock( upSyncKind, ) diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt index c4c55f0229..a49f77c8a2 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt @@ -101,19 +101,21 @@ class SyncInfoViewModelTest { stateLiveData = MutableLiveData() every { eventSyncManager.getLastSyncState() } returns stateLiveData coEvery { configManager.getProject(PROJECT_ID) } returns project - viewModel = SyncInfoViewModel( - configManager = configManager, - connectivityTracker = connectivityTracker, - enrolmentRecordRepository = enrolmentRecordRepository, - authStore = authStore, - imageRepository = imageRepository, - eventSyncManager = eventSyncManager, - syncOrchestrator = syncOrchestrator, - tokenizationProcessor = tokenizationProcessor, - recentUserActivityManager = recentUserActivityManager, - ) + viewModel = createViewModel() } + private fun createViewModel() = SyncInfoViewModel( + configManager = configManager, + connectivityTracker = connectivityTracker, + enrolmentRecordRepository = enrolmentRecordRepository, + authStore = authStore, + imageRepository = imageRepository, + eventSyncManager = eventSyncManager, + syncOrchestrator = syncOrchestrator, + tokenizationProcessor = tokenizationProcessor, + recentUserActivityManager = recentUserActivityManager, + ) + @Test fun `should initialize the configuration live data correctly`() = runTest { val configuration = mockk(relaxed = true) @@ -138,10 +140,11 @@ class SyncInfoViewModelTest { fun `should initialize the recordsToUpSync live data correctly`() = runTest { val number = 10 coEvery { - eventSyncManager.countEventsToUpload(EventType.ENROLMENT_V2) + eventSyncManager.countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)) } returns flowOf(number) - viewModel.refreshInformation() + // upSync count collected on init, so need to rebuild for mocking to take effect + viewModel = createViewModel() assertThat(viewModel.recordsToUpSync.getOrAwaitValue()).isEqualTo(number) } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt index a516c9a323..9288ccb834 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt @@ -18,7 +18,9 @@ interface EventSyncManager { fun getLastSyncState(): LiveData - suspend fun countEventsToUpload(type: EventType?): Flow + suspend fun countEventsToUpload(): Flow + + suspend fun countEventsToUpload(types: List): Flow suspend fun countEventsToDownload(): DownSyncCounts diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt index 4389d8e86b..ffbcaffcf1 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt @@ -27,6 +27,7 @@ import com.simprints.infra.eventsync.sync.common.TAG_SUBJECTS_SYNC_ALL_WORKERS import com.simprints.infra.eventsync.sync.down.tasks.EventDownSyncTask import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.toList import kotlinx.coroutines.withContext import javax.inject.Inject @@ -61,7 +62,11 @@ internal class EventSyncManagerImpl @Inject constructor( override fun getAllWorkerTag(): String = TAG_SUBJECTS_SYNC_ALL_WORKERS - override suspend fun countEventsToUpload(type: EventType?): Flow = eventRepository.observeEventCount(type) + override suspend fun countEventsToUpload(): Flow = eventRepository.observeEventCount(null) + + override suspend fun countEventsToUpload(types: List): Flow = combine( + types.map { eventRepository.observeEventCount(it) }, + ) { it.sum() } override suspend fun countEventsToDownload(): DownSyncCounts { val projectConfig = configRepository.getProjectConfiguration() diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt index bb19c40035..776c8c9105 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt @@ -9,6 +9,7 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Project import com.simprints.infra.events.EventRepository import com.simprints.infra.events.event.domain.EventCount +import com.simprints.infra.events.event.domain.models.EventType import com.simprints.infra.events.event.domain.models.scope.EventScope import com.simprints.infra.events.sampledata.SampleDefaults.DEFAULT_MODULE_ID import com.simprints.infra.events.sampledata.SampleDefaults.DEFAULT_MODULE_ID_2 @@ -113,10 +114,17 @@ internal class EventSyncManagerTest { } @Test - fun `countEventsToUpload should call event repo`() = runTest { - eventSyncManagerImpl.countEventsToUpload(null).toList() + fun `countEventsToUpload without types should call event repo`() = runTest { + eventSyncManagerImpl.countEventsToUpload().toList() - coVerify { eventRepository.observeEventCount(any()) } + coVerify { eventRepository.observeEventCount(null) } + } + + @Test + fun `countEventsToUpload with types should call event repo per type`() = runTest { + eventSyncManagerImpl.countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.EVENT_UP_SYNC_REQUEST)).toList() + + coVerify(exactly = 2) { eventRepository.observeEventCount(any()) } } @Test diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/HandleProjectStateUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/HandleProjectStateUseCase.kt index 01b1065500..cead58d503 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/HandleProjectStateUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/HandleProjectStateUseCase.kt @@ -18,7 +18,7 @@ internal class HandleProjectStateUseCase @Inject constructor( private suspend fun shouldSignOut(projectState: ProjectState): Boolean { val isProjectEnded = projectState == ProjectState.PROJECT_ENDED val isProjectEnding = projectState == ProjectState.PROJECT_ENDING - val hasNoEventsToUpload = eventSyncManager.countEventsToUpload(null).first() == 0 + val hasNoEventsToUpload = eventSyncManager.countEventsToUpload().first() == 0 return isProjectEnded || (isProjectEnding && hasNoEventsToUpload) } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/HandleProjectStateUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/HandleProjectStateUseCaseTest.kt index 31537699dc..93a006fefd 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/HandleProjectStateUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/HandleProjectStateUseCaseTest.kt @@ -32,7 +32,7 @@ internal class HandleProjectStateUseCaseTest { @Test fun `Fully logs out when project has ended`() = runTest { - coEvery { eventSyncManager.countEventsToUpload(null) } returns flowOf(0) + coEvery { eventSyncManager.countEventsToUpload() } returns flowOf(0) useCase(ProjectState.PROJECT_ENDED) @@ -41,7 +41,7 @@ internal class HandleProjectStateUseCaseTest { @Test fun `Logs out when project has ending and no items to upload`() = runTest { - coEvery { eventSyncManager.countEventsToUpload(null) } returns flowOf(0) + coEvery { eventSyncManager.countEventsToUpload() } returns flowOf(0) useCase(ProjectState.PROJECT_ENDING) @@ -50,7 +50,7 @@ internal class HandleProjectStateUseCaseTest { @Test fun `Does not logs out when project has ending and has items to upload`() = runTest { - coEvery { eventSyncManager.countEventsToUpload(null) } returns flowOf(5) + coEvery { eventSyncManager.countEventsToUpload() } returns flowOf(5) useCase(ProjectState.PROJECT_ENDING) @@ -59,7 +59,7 @@ internal class HandleProjectStateUseCaseTest { @Test fun `Does not logs out when project is running`() = runTest { - coEvery { eventSyncManager.countEventsToUpload(null) } returns flowOf(0) + coEvery { eventSyncManager.countEventsToUpload() } returns flowOf(0) useCase(ProjectState.RUNNING) From b0c5e8abba4c8ba1bde9c9412b1d07d54d485ee4 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Tue, 8 Jul 2025 11:03:32 +0300 Subject: [PATCH 8/8] Revert "Bump retrofit_version from 2.9.0 to 3.0.0" --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17a73cef49..5b63f2e170 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ firebase_perfPlugin_version = "1.4.2" firebase_distrtibutionPlugin_version = "5.0.0" # [MS-483] Newer versions of Retrofit (>=2.10.0) don't support android apis <=25 beacuse of the internal Jackson library -retrofit_version = "3.0.0" +retrofit_version = "2.9.0" okttp_version = "4.12.0" # Newer versions of Jackson don't support android apis <=25.Jackson shouldn't be updated as long as SID MIN supported APIs <=25 jackson_version = "2.13.4"