diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 834f703b55..b83003b372 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -32,6 +32,7 @@ jobs: infra:orchestrator-data infra:enrolment-records:repository infra:enrolment-records:realm-store + infra:enrolment-records:room-store infra:recent-user-activity infra:config-store infra:config-sync diff --git a/build-logic/convention/src/main/kotlin/LibraryRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/LibraryRoomConventionPlugin.kt index 78437df6df..c9975e1935 100644 --- a/build-logic/convention/src/main/kotlin/LibraryRoomConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/LibraryRoomConventionPlugin.kt @@ -1,5 +1,6 @@ import androidx.room.gradle.RoomExtension import com.android.build.api.dsl.LibraryExtension +import common.api import common.configureDbEncryptionBuild import common.getLibs import common.implementation @@ -38,7 +39,7 @@ class LibraryRoomConventionPlugin : Plugin { val libs = getLibs() dependencies { - implementation(libs, "androidX.Room.core") + api(libs, "androidX.Room.core") implementation(libs, "androidX.Room.ktx") ksp(libs, "androidX.Room.compiler") diff --git a/build-logic/convention/src/main/kotlin/common/BuildTypeConfigurations.kt b/build-logic/convention/src/main/kotlin/common/BuildTypeConfigurations.kt index e716c7f8e3..fca329d3e4 100644 --- a/build-logic/convention/src/main/kotlin/common/BuildTypeConfigurations.kt +++ b/build-logic/convention/src/main/kotlin/common/BuildTypeConfigurations.kt @@ -37,7 +37,13 @@ fun Project.configureDbEncryptionBuild() { } getByName(BuildTypes.DEBUG) { - buildConfigField("Boolean", "DB_ENCRYPTION", "$propDbEncrypted") + // Set false only if running tests + val isTestTask = gradle.startParameter.taskNames.any { + it.contains("test", ignoreCase = true) || it.contains("connected", ignoreCase = true) + } + + val dbEncryptionValue = if (isTestTask) "false" else "$propDbEncrypted" + buildConfigField("Boolean", "DB_ENCRYPTION", dbEncryptionValue) } } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt index 06b797e647..3358fe7f4a 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt @@ -1,12 +1,12 @@ package com.simprints.feature.dashboard.debug -import android.graphics.Color import android.os.Bundle import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import android.view.View +import androidx.core.graphics.toColorInt import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.work.WorkManager @@ -14,7 +14,7 @@ import com.simprints.core.DispatcherIO import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentDebugBinding import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.enrolment.records.repository.local.EnrolmentRecordLocalDataSource +import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.events.EventRepository import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.EventSyncWorkerState @@ -43,7 +43,7 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { lateinit var eventRepository: EventRepository @Inject - lateinit var enrolmentRecordRepository: EnrolmentRecordLocalDataSource + lateinit var enrolmentRecordRepository: EnrolmentRecordRepository @Inject @DispatcherIO @@ -71,7 +71,7 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { val ssb = SpannableStringBuilder( coloredText( "\n$message", - Color.parseColor(getRandomColor()), + getRandomColor().toColorInt(), ), ) diff --git a/feature/matcher/src/main/java/com/simprints/matcher/usecases/FaceMatcherUseCase.kt b/feature/matcher/src/main/java/com/simprints/matcher/usecases/FaceMatcherUseCase.kt index 293cf9ed11..70157fc190 100644 --- a/feature/matcher/src/main/java/com/simprints/matcher/usecases/FaceMatcherUseCase.kt +++ b/feature/matcher/src/main/java/com/simprints/matcher/usecases/FaceMatcherUseCase.kt @@ -1,8 +1,6 @@ package com.simprints.matcher.usecases -import com.simprints.core.AvailableProcessors import com.simprints.core.DispatcherBG -import com.simprints.core.DispatcherIO import com.simprints.face.infra.basebiosdk.matching.FaceIdentity import com.simprints.face.infra.basebiosdk.matching.FaceMatcher import com.simprints.face.infra.basebiosdk.matching.FaceSample @@ -20,19 +18,15 @@ import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject -import kotlin.math.min import com.simprints.infra.enrolment.records.repository.domain.models.FaceIdentity as DomainFaceIdentity internal class FaceMatcherUseCase @Inject constructor( private val enrolmentRecordRepository: EnrolmentRecordRepository, private val resolveFaceBioSdk: ResolveFaceBioSdkUseCase, private val createRanges: CreateRangesUseCase, - @AvailableProcessors private val availableProcessors: Int, @DispatcherBG private val dispatcherBG: CoroutineDispatcher, - @DispatcherIO private val dispatcherIO: CoroutineDispatcher, ) : MatcherUseCase { override val crashReportTag = LoggingConstants.CrashReportTag.FACE_MATCHING @@ -73,9 +67,6 @@ internal class FaceMatcherUseCase @Inject constructor( // as it's count function does not take into account filtering criteria val loadedCandidates = AtomicInteger(0) val ranges = createRanges(expectedCandidates) - // if number of ranges less than the number of cores then use the number of ranges - val numConsumers = min(availableProcessors, ranges.size) - val resultSet = MatchResultSet() val candidatesChannel = enrolmentRecordRepository .loadFaceIdentities( @@ -84,22 +75,14 @@ internal class FaceMatcherUseCase @Inject constructor( dataSource = matchParams.biometricDataSource, project = project, scope = this, - onCandidateLoaded = { - loadedCandidates.incrementAndGet() - this.trySend(MatcherState.CandidateLoaded) - }, - ) - - // Start Consumers in BG thread - val consumerJobs = List(numConsumers) { - launch(dispatcherBG) { - consumeAndMatch(candidatesChannel, samples, resultSet, bioSdk) + ) { + loadedCandidates.incrementAndGet() + this@channelFlow.send(MatcherState.CandidateLoaded) } - } - // Wait for all to complete - consumerJobs.forEach { it.join() } + + consumeAndMatch(candidatesChannel, samples, resultSet, bioSdk) send(MatcherState.Success(resultSet.toList(), loadedCandidates.get(), bioSdk.matcherName)) - }.flowOn(dispatcherIO) + }.flowOn(dispatcherBG) suspend fun consumeAndMatch( candidatesChannel: ReceiveChannel>, diff --git a/feature/matcher/src/main/java/com/simprints/matcher/usecases/FingerprintMatcherUseCase.kt b/feature/matcher/src/main/java/com/simprints/matcher/usecases/FingerprintMatcherUseCase.kt index 1d6f0d6557..1c1a91e2b0 100644 --- a/feature/matcher/src/main/java/com/simprints/matcher/usecases/FingerprintMatcherUseCase.kt +++ b/feature/matcher/src/main/java/com/simprints/matcher/usecases/FingerprintMatcherUseCase.kt @@ -1,8 +1,6 @@ package com.simprints.matcher.usecases -import com.simprints.core.AvailableProcessors import com.simprints.core.DispatcherBG -import com.simprints.core.DispatcherIO import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.fingerprint.IFingerIdentifier import com.simprints.fingerprint.infra.basebiosdk.matching.domain.FingerIdentifier @@ -25,10 +23,8 @@ import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject -import kotlin.math.min import com.simprints.infra.enrolment.records.repository.domain.models.FingerprintIdentity as DomainFingerprintIdentity internal class FingerprintMatcherUseCase @Inject constructor( @@ -36,9 +32,7 @@ internal class FingerprintMatcherUseCase @Inject constructor( private val resolveBioSdkWrapper: ResolveBioSdkWrapperUseCase, private val configManager: ConfigManager, private val createRanges: CreateRangesUseCase, - @AvailableProcessors private val availableProcessors: Int, @DispatcherBG private val dispatcherBG: CoroutineDispatcher, - @DispatcherIO private val dispatcherIO: CoroutineDispatcher, ) : MatcherUseCase { override val crashReportTag = LoggingConstants.CrashReportTag.FINGER_MATCHING @@ -79,7 +73,6 @@ internal class FingerprintMatcherUseCase @Inject constructor( val loadedCandidates = AtomicInteger(0) val ranges = createRanges(expectedCandidates) // if number of ranges less than the number of cores then use the number of ranges - val numConsumers = min(availableProcessors, ranges.size) val channel = enrolmentRecordRepository.loadFingerprintIdentities( query = queryWithSupportedFormat, ranges = ranges, @@ -88,23 +81,16 @@ internal class FingerprintMatcherUseCase @Inject constructor( project = project, ) { loadedCandidates.incrementAndGet() - trySend(MatcherState.CandidateLoaded) + this@channelFlow.send(MatcherState.CandidateLoaded) } val resultSet = MatchResultSet() - // Start Consumers in BG thread - val consumerJobs = List(numConsumers) { - launch(dispatcherBG) { - consumeAndMatch(channel, samples, resultSet, bioSdkWrapper, matchParams) - } - } - // Wait for all to complete - consumerJobs.forEach { it.join() } + consumeAndMatch(channel, samples, resultSet, bioSdkWrapper, matchParams) Simber.i("Matched $loadedCandidates candidates", tag = crashReportTag) send(MatcherState.Success(resultSet.toList(), loadedCandidates.get(), bioSdkWrapper.matcherName)) - }.flowOn(dispatcherIO) + }.flowOn(dispatcherBG) private suspend fun consumeAndMatch( channel: ReceiveChannel>, diff --git a/feature/matcher/src/test/java/com/simprints/matcher/usecases/FaceMatcherUseCaseTest.kt b/feature/matcher/src/test/java/com/simprints/matcher/usecases/FaceMatcherUseCaseTest.kt index 9aa941e8c4..04015195a6 100644 --- a/feature/matcher/src/test/java/com/simprints/matcher/usecases/FaceMatcherUseCaseTest.kt +++ b/feature/matcher/src/test/java/com/simprints/matcher/usecases/FaceMatcherUseCaseTest.kt @@ -18,6 +18,7 @@ import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -54,8 +55,6 @@ internal class FaceMatcherUseCaseTest { enrolmentRecordRepository, resolveFaceBioSdk, createRangesUseCase, - 4, - testCoroutineRule.testCoroutineDispatcher, testCoroutineRule.testCoroutineDispatcher, ) } @@ -127,10 +126,14 @@ internal class FaceMatcherUseCaseTest { ) coEvery { enrolmentRecordRepository.count(any(), any()) } returns 1 coEvery { createRangesUseCase(any()) } returns listOf(0..99) - coEvery { enrolmentRecordRepository.loadFaceIdentities(any(), any(), any(), any(), any(), any()) } coAnswers { + every { + enrolmentRecordRepository.loadFaceIdentities(any(), any(), any(), any(), any(), any()) + } answers { // Call the onCandidateLoaded callback (5th parameter) - val onCandidateLoaded = arg<() -> Unit>(5) - onCandidateLoaded() + val onCandidateLoaded: suspend () -> Unit = arg(5) + runBlocking { + onCandidateLoaded() + } // Return the face identities createTestChannel(faceIdentities) diff --git a/feature/matcher/src/test/java/com/simprints/matcher/usecases/FingerprintMatcherUseCaseTest.kt b/feature/matcher/src/test/java/com/simprints/matcher/usecases/FingerprintMatcherUseCaseTest.kt index 465241b33f..8fb25993a1 100644 --- a/feature/matcher/src/test/java/com/simprints/matcher/usecases/FingerprintMatcherUseCaseTest.kt +++ b/feature/matcher/src/test/java/com/simprints/matcher/usecases/FingerprintMatcherUseCaseTest.kt @@ -66,8 +66,6 @@ internal class FingerprintMatcherUseCaseTest { resolveBioSdkWrapperUseCase, configManager, createRangesUseCase, - 4, - testCoroutineRule.testCoroutineDispatcher, testCoroutineRule.testCoroutineDispatcher, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c03b0b807a..5fd9ec59f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -252,7 +252,6 @@ testing-AndroidX-core = { module = "androidx.arch.core:core-testing", version.re testing-AndroidX-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx_test_orchestrator_version" } testing-AndroidX-runner = { module = "androidx.test:runner", version.ref = "androidx_version" } testing-AndroidX-room = { module = "androidx.room:room-testing", version.ref = "androidx_room_version" } -testing-AndroidX-navigation = { module = "androidx.navigation:navigation-testing", version.ref = "androidx_navigation_version" } testing-AndroidX-uiAutomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiAutomator_version" } # Dependencies of the included build-logic diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/tokenization/TokenizationProcessor.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/tokenization/TokenizationProcessor.kt index 3d270a923c..d7a8dde943 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/tokenization/TokenizationProcessor.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/tokenization/TokenizationProcessor.kt @@ -50,7 +50,7 @@ class TokenizationProcessor @Inject constructor( encrypted: TokenizableString.Tokenized, tokenKeyType: TokenKeyType, project: Project, - logError: Boolean = true + logError: Boolean = true, ): TokenizableString { val moduleKeyset = project.tokenizationKeys[tokenKeyType] ?: return encrypted return try { @@ -62,4 +62,18 @@ class TokenizationProcessor @Inject constructor( encrypted } } + + fun tokenizeIfNecessary( + tokenizableString: TokenizableString, + tokenKeyType: TokenKeyType, + project: Project, + ) = when (tokenizableString) { + is TokenizableString.Raw -> encrypt( + decrypted = tokenizableString, + tokenKeyType = tokenKeyType, + project = project, + ) + + is TokenizableString.Tokenized -> tokenizableString + } } diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectRealmMigrationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectRealmMigrationTest.kt index 64c0397a0c..e8c6881026 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectRealmMigrationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectRealmMigrationTest.kt @@ -18,6 +18,7 @@ import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm import io.realm.kotlin.query.RealmQuery import io.realm.kotlin.query.RealmSingleQuery +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -43,7 +44,7 @@ class ProjectRealmMigrationTest { private lateinit var realmQuery: RealmQuery private lateinit var blockCapture: CapturingSlot<(MutableRealm) -> Any> - private lateinit var readBlockCapture: CapturingSlot<(Realm) -> Any> + private lateinit var readBlockCapture: CapturingSlot Any> private lateinit var projectRealmMigration: ProjectRealmMigration @@ -56,7 +57,7 @@ class ProjectRealmMigrationTest { every { realm.query(any(), any(), any()) } returns realmQuery coEvery { realmWrapper.readRealm(capture(readBlockCapture)) } answers { - readBlockCapture.captured.invoke(realm) + runBlocking { readBlockCapture.captured.invoke(realm) } } coEvery { realmWrapper.writeRealm(capture(blockCapture)) } answers { blockCapture.captured.invoke(mutableRealm) diff --git a/infra/core/src/main/java/com/simprints/core/domain/face/FaceSample.kt b/infra/core/src/main/java/com/simprints/core/domain/face/FaceSample.kt index fbf1938b6b..57b44fd5f9 100644 --- a/infra/core/src/main/java/com/simprints/core/domain/face/FaceSample.kt +++ b/infra/core/src/main/java/com/simprints/core/domain/face/FaceSample.kt @@ -19,9 +19,7 @@ data class FaceSample( other as FaceSample - if (!template.contentEquals(other.template)) return false - - return true + return template.contentEquals(other.template) } override fun hashCode(): Int = template.contentHashCode() diff --git a/infra/enrolment-records/realm-store/src/main/java/com/simprints/infra/enrolment/records/realm/store/RealmWrapper.kt b/infra/enrolment-records/realm-store/src/main/java/com/simprints/infra/enrolment/records/realm/store/RealmWrapper.kt index c3d71bbb3b..4790990ddc 100644 --- a/infra/enrolment-records/realm-store/src/main/java/com/simprints/infra/enrolment/records/realm/store/RealmWrapper.kt +++ b/infra/enrolment-records/realm-store/src/main/java/com/simprints/infra/enrolment/records/realm/store/RealmWrapper.kt @@ -7,7 +7,7 @@ interface RealmWrapper { /** * Returns read-only Realm instance for data fetching. */ - suspend fun readRealm(block: (Realm) -> R): R + suspend fun readRealm(block: suspend (Realm) -> R): R /** * Executes provided block with a writable Realm instance ensuring 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 0514544a8e..35307646e7 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 @@ -86,7 +86,7 @@ class RealmWrapperImpl @Inject constructor( /** * Executes provided block ensuring a valid Realm instance is used and closed. */ - override suspend fun readRealm(block: (Realm) -> R): R = withContext(dispatcher) { block(getRealm()) } + override suspend fun readRealm(block: suspend (Realm) -> R): R = withContext(dispatcher) { block(getRealm()) } /** * Executes provided block in a transaction ensuring a valid Realm instance is used and closed. diff --git a/infra/enrolment-records/repository/build.gradle.kts b/infra/enrolment-records/repository/build.gradle.kts index 17bcdc6f6e..e87c549e8a 100644 --- a/infra/enrolment-records/repository/build.gradle.kts +++ b/infra/enrolment-records/repository/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(project(":infra:config-store")) implementation(project(":infra:auth-store")) implementation(project(":infra:enrolment-records:realm-store")) + implementation(project(":infra:enrolment-records:room-store")) implementation(project(":infra:events")) implementation(libs.libsimprints) diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImpl.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImpl.kt index d5f64e4d66..38e286c804 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImpl.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImpl.kt @@ -9,9 +9,11 @@ import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.enrolment.records.realm.store.exceptions.RealmUninitialisedException import com.simprints.infra.enrolment.records.repository.domain.models.BiometricDataSource +import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.enrolment.records.repository.local.EnrolmentRecordLocalDataSource +import com.simprints.infra.enrolment.records.repository.local.SelectEnrolmentRecordLocalDataSourceUseCase import com.simprints.infra.enrolment.records.repository.remote.EnrolmentRecordRemoteDataSource import com.simprints.infra.logging.Simber import dagger.hilt.android.qualifiers.ApplicationContext @@ -20,42 +22,24 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import javax.inject.Inject -internal class EnrolmentRecordRepositoryImpl( - context: Context, +internal class EnrolmentRecordRepositoryImpl @Inject constructor( + @ApplicationContext context: Context, private val remoteDataSource: EnrolmentRecordRemoteDataSource, - private val localDataSource: EnrolmentRecordLocalDataSource, - private val commCareDataSource: IdentityDataSource, + @CommCareDataSource private val commCareDataSource: IdentityDataSource, private val tokenizationProcessor: TokenizationProcessor, - private val dispatcher: CoroutineDispatcher, - private val batchSize: Int, -) : EnrolmentRecordRepository, - EnrolmentRecordLocalDataSource by localDataSource { - @Inject - constructor( - @ApplicationContext context: Context, - remoteDataSource: EnrolmentRecordRemoteDataSource, - localDataSource: EnrolmentRecordLocalDataSource, - @CommCareDataSource commCareDataSource: IdentityDataSource, - tokenizationProcessor: TokenizationProcessor, - @DispatcherIO dispatcher: CoroutineDispatcher, - ) : this( - context = context, - remoteDataSource = remoteDataSource, - localDataSource = localDataSource, - commCareDataSource = commCareDataSource, - tokenizationProcessor = tokenizationProcessor, - dispatcher = dispatcher, - batchSize = BATCH_SIZE, - ) - + private val selectEnrolmentRecordLocalDataSource: SelectEnrolmentRecordLocalDataSourceUseCase, + @DispatcherIO private val dispatcher: CoroutineDispatcher, + @EnrolmentBatchSize private val batchSize: Int, +) : EnrolmentRecordRepository { private val prefs = context.getSharedPreferences(PREF_FILE_NAME, Context.MODE_PRIVATE) companion object { - private const val BATCH_SIZE = 80 private const val PREF_FILE_NAME = "UPLOAD_ENROLMENT_RECORDS_PROGRESS" private const val PROGRESS_KEY = "PROGRESS" } + private val localDataSource: EnrolmentRecordLocalDataSource by lazy { selectEnrolmentRecordLocalDataSource() } + override suspend fun uploadRecords(subjectIds: List) = withContext(dispatcher) { val lastUploadedRecord = prefs.getString(PROGRESS_KEY, null) var query = SubjectQuery(sort = true, afterSubjectId = lastUploadedRecord) @@ -125,7 +109,7 @@ internal class EnrolmentRecordRepositoryImpl( dataSource: BiometricDataSource, project: Project, scope: CoroutineScope, - onCandidateLoaded: () -> Unit, + onCandidateLoaded: suspend () -> Unit, ) = fromIdentityDataSource(dataSource).loadFingerprintIdentities( query = query, ranges = ranges, @@ -141,7 +125,7 @@ internal class EnrolmentRecordRepositoryImpl( dataSource: BiometricDataSource, project: Project, scope: CoroutineScope, - onCandidateLoaded: () -> Unit, + onCandidateLoaded: suspend () -> Unit, ) = fromIdentityDataSource(dataSource).loadFaceIdentities( query = query, ranges = ranges, @@ -155,4 +139,15 @@ internal class EnrolmentRecordRepositoryImpl( is BiometricDataSource.Simprints -> localDataSource is BiometricDataSource.CommCare -> commCareDataSource } + + override suspend fun load(query: SubjectQuery): List = localDataSource.load(query) + + override suspend fun delete(queries: List) = localDataSource.delete(queries) + + override suspend fun deleteAll() = localDataSource.deleteAll() + + override suspend fun performActions( + actions: List, + project: Project, + ) = localDataSource.performActions(actions, project) } diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordsStoreModule.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordsStoreModule.kt index 5aca8df5ad..60f2199b8b 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordsStoreModule.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordsStoreModule.kt @@ -6,8 +6,6 @@ import com.simprints.core.DispatcherIO import com.simprints.core.tools.json.JsonHelper import com.simprints.core.tools.utils.EncodingUtils import com.simprints.infra.enrolment.records.repository.commcare.CommCareIdentityDataSource -import com.simprints.infra.enrolment.records.repository.local.EnrolmentRecordLocalDataSource -import com.simprints.infra.enrolment.records.repository.local.EnrolmentRecordLocalDataSourceImpl import com.simprints.infra.enrolment.records.repository.remote.EnrolmentRecordRemoteDataSource import com.simprints.infra.enrolment.records.repository.remote.EnrolmentRecordRemoteDataSourceImpl import com.simprints.infra.enrolment.records.repository.usecases.CompareImplicitTokenizedStringsUseCase @@ -30,9 +28,6 @@ abstract class EnrolmentRecordsStoreModule { @Binds internal abstract fun bindEnrolmentRecordRepository(impl: EnrolmentRecordRepositoryImpl): EnrolmentRecordRepository - @Binds - internal abstract fun bindEnrolmentRecordLocalDataSource(impl: EnrolmentRecordLocalDataSourceImpl): EnrolmentRecordLocalDataSource - @Binds internal abstract fun bindEnrolmentRecordRemoteDataSource(impl: EnrolmentRecordRemoteDataSourceImpl): EnrolmentRecordRemoteDataSource } @@ -57,8 +52,20 @@ class IdentityDataSourceModule { context = context, dispatcher = dispatcher, ) + + @EnrolmentBatchSize + @Provides + fun provideBatchSize(): Int = BATCH_SIZE + + companion object { + const val BATCH_SIZE = 80 + } } @Qualifier @Retention(AnnotationRetention.BINARY) annotation class CommCareDataSource + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class EnrolmentBatchSize diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/IdentityDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/IdentityDataSource.kt index 7aceac3deb..6918a7e759 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/IdentityDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/IdentityDataSource.kt @@ -5,14 +5,8 @@ import com.simprints.infra.enrolment.records.repository.domain.models.BiometricD import com.simprints.infra.enrolment.records.repository.domain.models.FaceIdentity import com.simprints.infra.enrolment.records.repository.domain.models.FingerprintIdentity import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit interface IdentityDataSource { suspend fun count( @@ -26,7 +20,7 @@ interface IdentityDataSource { dataSource: BiometricDataSource, project: Project, scope: CoroutineScope, - onCandidateLoaded: () -> Unit, + onCandidateLoaded: suspend () -> Unit, ): ReceiveChannel> fun loadFaceIdentities( @@ -35,33 +29,11 @@ interface IdentityDataSource { dataSource: BiometricDataSource, project: Project, scope: CoroutineScope, - onCandidateLoaded: () -> Unit, + onCandidateLoaded: suspend () -> Unit, ): ReceiveChannel> /** * Loads identities concurrently using the provided dispatcher and parallelism level. * */ - fun loadIdentitiesConcurrently( - ranges: List, - dispatcher: CoroutineDispatcher, - parallelism: Int, - scope: CoroutineScope, - load: suspend (IntRange) -> List, - ): ReceiveChannel> { - val channel = Channel>(parallelism) - val semaphore = Semaphore(parallelism) - scope.launch { - ranges - .map { range -> - launch { - semaphore.withPermit { - channel.send(load(range)) - } - } - }.joinAll() - channel.close() - } - return channel - } } diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareIdentityDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareIdentityDataSource.kt index 7a3f58a4a3..1b9692a784 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareIdentityDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/commcare/CommCareIdentityDataSource.kt @@ -6,8 +6,8 @@ import android.net.Uri import androidx.core.net.toUri import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.module.SimpleModule -import com.simprints.core.DispatcherBG import com.simprints.core.AvailableProcessors +import com.simprints.core.DispatcherBG import com.simprints.core.domain.face.FaceSample import com.simprints.core.domain.fingerprint.FingerprintSample import com.simprints.core.domain.tokenization.TokenizableString @@ -33,7 +33,13 @@ import com.simprints.libsimprints.Constants.SIMPRINTS_COSYNC_SUBJECT_ACTIONS import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.json.JSONException import javax.inject.Inject @@ -50,37 +56,38 @@ internal class CommCareIdentityDataSource @Inject constructor( private fun getCaseDataUri(packageName: String): Uri = "content://$packageName.case/casedb/data".toUri() - private fun loadFingerprintIdentities( + private suspend fun loadFingerprintIdentities( query: SubjectQuery, range: IntRange, dataSource: BiometricDataSource, project: Project, - onCandidateLoaded: () -> Unit, - ): List = loadEnrolmentRecordCreationEvents(range, dataSource.callerPackageName(), query, project, onCandidateLoaded) - .filter { erce -> - erce.payload.biometricReferences.any { it is FingerprintReference && it.format == query.fingerprintSampleFormat } - }.map { - FingerprintIdentity( - it.payload.subjectId, - it.payload.biometricReferences.filterIsInstance().flatMap { fingerprintReference -> - fingerprintReference.templates.map { fingerprintTemplate -> - FingerprintSample( - fingerIdentifier = fingerprintTemplate.finger, - template = encoder.base64ToBytes(fingerprintTemplate.template), - format = fingerprintReference.format, - referenceId = fingerprintReference.id, - ) - } - }, - ) - } + onCandidateLoaded: suspend () -> Unit, + ): List = + loadEnrolmentRecordCreationEvents(range, dataSource.callerPackageName(), query, project, onCandidateLoaded) + .filter { erce -> + erce.payload.biometricReferences.any { it is FingerprintReference && it.format == query.fingerprintSampleFormat } + }.map { + FingerprintIdentity( + it.payload.subjectId, + it.payload.biometricReferences.filterIsInstance().flatMap { fingerprintReference -> + fingerprintReference.templates.map { fingerprintTemplate -> + FingerprintSample( + fingerIdentifier = fingerprintTemplate.finger, + template = encoder.base64ToBytes(fingerprintTemplate.template), + format = fingerprintReference.format, + referenceId = fingerprintReference.id, + ) + } + }, + ) + } - private fun loadEnrolmentRecordCreationEvents( + private suspend fun loadEnrolmentRecordCreationEvents( range: IntRange, callerPackageName: String, query: SubjectQuery, project: Project, - onCandidateLoaded: () -> Unit, + onCandidateLoaded: suspend () -> Unit, ): List { val enrolmentRecordCreationEvents: MutableList = mutableListOf() try { @@ -100,9 +107,10 @@ internal class CommCareIdentityDataSource @Inject constructor( if (caseMetadataCursor.moveToPosition(range.first)) { do { caseMetadataCursor.getString(caseMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID))?.let { caseId -> - enrolmentRecordCreationEvents.addAll( - loadEnrolmentRecordCreationEvents(caseId, callerPackageName, query, project), - ).also { onCandidateLoaded() } + enrolmentRecordCreationEvents + .addAll( + loadEnrolmentRecordCreationEvents(caseId, callerPackageName, query, project), + ).also { onCandidateLoaded() } } } while (caseMetadataCursor.moveToNext() && caseMetadataCursor.position <= range.last) } @@ -122,12 +130,12 @@ internal class CommCareIdentityDataSource @Inject constructor( } } - private fun loadFaceIdentities( + private suspend fun loadFaceIdentities( query: SubjectQuery, range: IntRange, dataSource: BiometricDataSource, project: Project, - onCandidateLoaded: () -> Unit, + onCandidateLoaded: suspend () -> Unit, ): List = loadEnrolmentRecordCreationEvents(range, dataSource.callerPackageName(), query, project, onCandidateLoaded) .filter { erce -> erce.payload.biometricReferences.any { it is FaceReference && it.format == query.faceSampleFormat } @@ -265,11 +273,9 @@ internal class CommCareIdentityDataSource @Inject constructor( dataSource: BiometricDataSource, project: Project, scope: CoroutineScope, - onCandidateLoaded: () -> Unit, + onCandidateLoaded: suspend () -> Unit, ): ReceiveChannel> = loadIdentitiesConcurrently( ranges = ranges, - dispatcher = dispatcher, - parallelism = availableProcessors, scope = scope, ) { range -> loadFaceIdentities( @@ -287,11 +293,9 @@ internal class CommCareIdentityDataSource @Inject constructor( dataSource: BiometricDataSource, project: Project, scope: CoroutineScope, - onCandidateLoaded: () -> Unit, + onCandidateLoaded: suspend () -> Unit, ): ReceiveChannel> = loadIdentitiesConcurrently( ranges = ranges, - dispatcher = dispatcher, - parallelism = availableProcessors, scope = scope, ) { range -> loadFingerprintIdentities( @@ -303,11 +307,31 @@ internal class CommCareIdentityDataSource @Inject constructor( ) } + fun loadIdentitiesConcurrently( + ranges: List, + scope: CoroutineScope, + load: suspend (IntRange) -> List, + ): ReceiveChannel> { + val channel = Channel>(availableProcessors) + val semaphore = Semaphore(availableProcessors) + scope.launch(dispatcher) { + ranges + .map { range -> + async { + semaphore.withPermit { + channel.send(load(range)) + } + } + }.joinAll() + channel.close() + } + return channel + } + companion object { const val COLUMN_CASE_ID = "case_id" const val COLUMN_DATUM_ID = "datum_id" const val COLUMN_VALUE = "value" - const val ARG_CASE_ID = "caseId" } } diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/domain/models/FingerIdentifier.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/domain/models/FingerIdentifier.kt index f47e72f45d..50ed8caf34 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/domain/models/FingerIdentifier.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/domain/models/FingerIdentifier.kt @@ -3,6 +3,9 @@ package com.simprints.infra.enrolment.records.repository.domain.models import androidx.annotation.Keep import com.simprints.core.domain.fingerprint.IFingerIdentifier +// **IMPORTANT**: Do NOT change the order of this enum as it is used in the database by index. +// Changing the order of the entries in this enum will lead to data corruption or mismatches +// when retrieving or storing data in the database. Always append new entries at the end. @Keep enum class FingerIdentifier { RIGHT_5TH_FINGER, diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/EnrolmentRecordLocalDataSourceImpl.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSource.kt similarity index 74% rename from infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/EnrolmentRecordLocalDataSourceImpl.kt rename to infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSource.kt index 5f9347936a..8866a9809c 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/EnrolmentRecordLocalDataSourceImpl.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSource.kt @@ -1,13 +1,10 @@ package com.simprints.infra.enrolment.records.repository.local import com.simprints.core.DispatcherIO -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.enrolment.records.realm.store.RealmWrapper -import com.simprints.infra.enrolment.records.realm.store.models.DbFaceSample -import com.simprints.infra.enrolment.records.realm.store.models.DbFingerprintSample import com.simprints.infra.enrolment.records.realm.store.models.DbSubject import com.simprints.infra.enrolment.records.repository.domain.models.BiometricDataSource import com.simprints.infra.enrolment.records.repository.domain.models.FaceIdentity @@ -15,8 +12,8 @@ import com.simprints.infra.enrolment.records.repository.domain.models.Fingerprin import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery -import com.simprints.infra.enrolment.records.repository.local.models.fromDbToDomain -import com.simprints.infra.enrolment.records.repository.local.models.fromDomainToDb +import com.simprints.infra.enrolment.records.repository.local.models.toDomain +import com.simprints.infra.enrolment.records.repository.local.models.toRealmDb import com.simprints.infra.logging.LoggingConstants.CrashReportTag.REALM_DB import com.simprints.infra.logging.Simber import io.realm.kotlin.MutableRealm @@ -28,13 +25,17 @@ import io.realm.kotlin.query.find import io.realm.kotlin.types.RealmUUID import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Singleton -internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( +@Singleton +internal class RealmEnrolmentRecordLocalDataSource @Inject constructor( private val realmWrapper: RealmWrapper, private val tokenizationProcessor: TokenizationProcessor, - @DispatcherIO private val dispatcher: CoroutineDispatcher, + @DispatcherIO private val dispatcherIO: CoroutineDispatcher, ) : EnrolmentRecordLocalDataSource { companion object { const val PROJECT_ID_FIELD = "projectId" @@ -46,7 +47,9 @@ internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( const val FINGERPRINT_SAMPLES_FIELD = "fingerprintSamples" const val FACE_SAMPLES_FIELD = "faceSamples" const val FORMAT_FIELD = "format" - const val PARALLELISM = 1 + + // Although batches are processed sequentially, we use a small channel capacity to prevent blocking and reduce the risk of out-of-memory errors. + const val CHANNEL_CAPACITY = 4 } override suspend fun load(query: SubjectQuery): List = realmWrapper.readRealm { @@ -54,7 +57,7 @@ internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( .query(DbSubject::class) .buildRealmQueryForSubject(query) .find() - .map { dbSubject -> dbSubject.fromDbToDomain() } + .map { dbSubject -> dbSubject.toDomain() } } override fun loadFaceIdentities( @@ -63,18 +66,27 @@ internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( dataSource: BiometricDataSource, project: Project, scope: CoroutineScope, - onCandidateLoaded: () -> Unit, - ): ReceiveChannel> = loadIdentitiesConcurrently( - ranges = ranges, - dispatcher = dispatcher, - parallelism = PARALLELISM, - scope = scope, - ) { range -> - loadFaceIdentities( - query = query, - range = range, - onCandidateLoaded = onCandidateLoaded, - ) + onCandidateLoaded: suspend () -> Unit, + ): ReceiveChannel> { + val channel = Channel>(CHANNEL_CAPACITY) + scope.launch(dispatcherIO) { + ranges.forEach { range -> + val identities = loadIdentitiesRange( + query = query, + range = range, + mapper = { dbSubject -> + FaceIdentity( + subjectId = dbSubject.subjectId.toString(), + faces = dbSubject.faceSamples.map { it.toDomain() }, + ) + }, + onCandidateLoaded = onCandidateLoaded, + ) + channel.send(identities) + } + channel.close() + } + return channel } override fun loadFingerprintIdentities( @@ -83,55 +95,43 @@ internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( dataSource: BiometricDataSource, project: Project, scope: CoroutineScope, - onCandidateLoaded: () -> Unit, - ): ReceiveChannel> = loadIdentitiesConcurrently( - ranges = ranges, - dispatcher = dispatcher, - parallelism = PARALLELISM, - scope = scope, - ) { range -> - loadFingerprintIdentities( - query = query, - range = range, - onCandidateLoaded = onCandidateLoaded, - ) - } - - private suspend fun loadFingerprintIdentities( - query: SubjectQuery, - range: IntRange, - onCandidateLoaded: () -> Unit, - ): List = realmWrapper.readRealm { realm -> - realm - .query(DbSubject::class) - .buildRealmQueryForSubject(query) - // subList's second parameter is exclusive, so we need to add 1 to the last index - .find { it.subList(range.first, range.last+1) } - .map { subject -> - onCandidateLoaded() - FingerprintIdentity( - subject.subjectId.toString(), - subject.fingerprintSamples.map(DbFingerprintSample::fromDbToDomain), + onCandidateLoaded: suspend () -> Unit, + ): ReceiveChannel> { + val channel = Channel>(CHANNEL_CAPACITY) + scope.launch(dispatcherIO) { + ranges.forEach { range -> + val identities = loadIdentitiesRange( + query = query, + range = range, + mapper = { dbSubject -> + FingerprintIdentity( + subjectId = dbSubject.subjectId.toString(), + fingerprints = dbSubject.fingerprintSamples.map { it.toDomain() }, + ) + }, + onCandidateLoaded = onCandidateLoaded, ) + channel.send(identities) } + channel.close() + } + return channel } - private suspend fun loadFaceIdentities( + private suspend fun loadIdentitiesRange( query: SubjectQuery, range: IntRange, - onCandidateLoaded: () -> Unit, - ): List = realmWrapper.readRealm { realm -> + mapper: (DbSubject) -> T, + onCandidateLoaded: suspend () -> Unit, + ): List = realmWrapper.readRealm { realm -> realm .query(DbSubject::class) .buildRealmQueryForSubject(query) // subList's second parameter is exclusive, so we need to add 1 to the last index - .find { it.subList(range.first, range.last+1) } - .map { subject -> + .find { it.subList(range.first, range.last + 1) } + .map { dbSubject -> onCandidateLoaded() - FaceIdentity( - subject.subjectId.toString(), - subject.faceSamples.map(DbFaceSample::fromDbToDomain), - ) + mapper(dbSubject) } } @@ -177,9 +177,18 @@ internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( is SubjectAction.Creation -> { val newSubject = action.subject .copy( - moduleId = action.subject.moduleId.tokenizeIfNecessary(TokenKeyType.ModuleId, project), - attendantId = action.subject.attendantId.tokenizeIfNecessary(TokenKeyType.AttendantId, project), - ).fromDomainToDb() + moduleId = + tokenizationProcessor.tokenizeIfNecessary( + action.subject.moduleId, + TokenKeyType.ModuleId, + project, + ), + attendantId = tokenizationProcessor.tokenizeIfNecessary( + action.subject.attendantId, + TokenKeyType.AttendantId, + project, + ), + ).toRealmDb() val dbSubject: DbSubject? = realm.findSubject(newSubject.subjectId) if (dbSubject != null) { @@ -209,10 +218,10 @@ internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( // Append new samples to the list of samples that remain after removing dbSubject.faceSamples = ( - faceSamplesMap[false].orEmpty() + action.faceSamplesToAdd.map { it.fromDomainToDb() } + faceSamplesMap[false].orEmpty() + action.faceSamplesToAdd.map { it.toRealmDb() } ).toRealmList() dbSubject.fingerprintSamples = ( - fingerprintSamplesMap[false].orEmpty() + action.fingerprintSamplesToAdd.map { it.fromDomainToDb() } + fingerprintSamplesMap[false].orEmpty() + action.fingerprintSamplesToAdd.map { it.toRealmDb() } ).toRealmList() faceSamplesMap[true]?.forEach { realm.delete(it) } @@ -234,19 +243,6 @@ internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( } } - private fun TokenizableString.tokenizeIfNecessary( - tokenKeyType: TokenKeyType, - project: Project, - ) = when (this) { - is TokenizableString.Raw -> tokenizationProcessor.encrypt( - decrypted = this, - tokenKeyType = tokenKeyType, - project = project, - ) - - is TokenizableString.Tokenized -> this - } - private fun MutableRealm.findSubject(subjectId: RealmUUID): DbSubject? = query(DbSubject::class).query("$SUBJECT_ID_FIELD == $0", subjectId).first().find() 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 new file mode 100644 index 0000000000..daef9e8a5d --- /dev/null +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt @@ -0,0 +1,303 @@ +package com.simprints.infra.enrolment.records.repository.local + +import androidx.room.withTransaction +import com.simprints.core.DispatcherIO +import com.simprints.core.domain.face.FaceSample +import com.simprints.core.domain.fingerprint.FingerprintSample +import com.simprints.core.domain.fingerprint.IFingerIdentifier +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.enrolment.records.repository.domain.models.BiometricDataSource +import com.simprints.infra.enrolment.records.repository.domain.models.FaceIdentity +import com.simprints.infra.enrolment.records.repository.domain.models.FingerprintIdentity +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction +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.SubjectDao +import com.simprints.infra.enrolment.records.room.store.SubjectsDatabase +import com.simprints.infra.enrolment.records.room.store.SubjectsDatabaseFactory +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate +import com.simprints.infra.enrolment.records.room.store.models.DbSubject +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ROOM_RECORDS_DB +import com.simprints.infra.logging.Simber +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import com.simprints.infra.enrolment.records.repository.domain.models.Subject as DomainSubject + +/** + * Local data source for enrolment records using Room. + */ +@Singleton +internal class RoomEnrolmentRecordLocalDataSource @Inject constructor( + private val subjectsDatabaseFactory: SubjectsDatabaseFactory, + private val tokenizationProcessor: TokenizationProcessor, + private val queryBuilder: RoomEnrolmentRecordQueryBuilder, + @DispatcherIO private val dispatcherIO: CoroutineDispatcher, +) : EnrolmentRecordLocalDataSource { + companion object { + // Although batches are processed sequentially, we use a small channel capacity to prevent blocking and reduce the risk of out-of-memory errors. + private const val CHANNEL_CAPACITY = 4 + } + + private val database: SubjectsDatabase by lazy { subjectsDatabaseFactory.get() } + private val subjectDao: SubjectDao by lazy { database.subjectDao } + + /** + * Loads subjects matching the given query. + * Don't use this method if the query contains format fields (faceSampleFormat or fingerprintSampleFormat). + * instead, use loadFaceIdentities or loadFingerprintIdentities methods. + */ + override suspend fun load(query: SubjectQuery): List = withContext(dispatcherIO) { + if (query.hasUntokenizedFields == true) { + Simber.d( + "[load] Query has untokenized fields, returning empty list as all records are tokenized.", + tag = ROOM_RECORDS_DB, + ) + return@withContext emptyList() + } + subjectDao.loadSubjects(queryBuilder.buildSubjectQuery(query)).map { it.toDomain() } + } + + /** + * Counts subjects matching the given query. + */ + override suspend fun count( + query: SubjectQuery, + dataSource: BiometricDataSource, + ): Int = withContext(dispatcherIO) { + subjectDao.countSubjects(queryBuilder.buildCountQuery(query)) + } + + /** + * Loads face identities in paged ranges. + */ + override fun loadFaceIdentities( + query: SubjectQuery, + ranges: List, + dataSource: BiometricDataSource, + project: Project, + scope: CoroutineScope, + onCandidateLoaded: suspend () -> Unit, + ): ReceiveChannel> = loadBiometricIdentitiesPaged( + query = query, + ranges = ranges, + format = requireNotNull(query.faceSampleFormat) { "faceSampleFormat required" }, + createIdentity = { subjectId, templates -> + FaceIdentity( + subjectId = subjectId, + faces = templates.map { sample -> + FaceSample( + template = sample.templateData, + id = sample.uuid, + format = sample.format, + referenceId = sample.referenceId, + ) + }, + ) + }, + onCandidateLoaded = onCandidateLoaded, + scope = scope, + ) + + /** + * Loads fingerprint identities in paged ranges. + */ + override fun loadFingerprintIdentities( + query: SubjectQuery, + ranges: List, + dataSource: BiometricDataSource, + project: Project, + scope: CoroutineScope, + onCandidateLoaded: suspend () -> Unit, + ): ReceiveChannel> = loadBiometricIdentitiesPaged( + query = query, + ranges = ranges, + format = requireNotNull(query.fingerprintSampleFormat) { "fingerprintSampleFormat required" }, + createIdentity = { subjectId, templates -> + FingerprintIdentity( + subjectId = subjectId, + fingerprints = templates.map { sample -> + FingerprintSample( + fingerIdentifier = IFingerIdentifier.entries[sample.identifier!!], + template = sample.templateData, + id = sample.uuid, + format = sample.format, + referenceId = sample.referenceId, + ) + }, + ) + }, + onCandidateLoaded = onCandidateLoaded, + scope = scope, + ) + + private fun loadBiometricIdentitiesPaged( + query: SubjectQuery, + ranges: List, + format: String, + createIdentity: (String, List) -> T, + onCandidateLoaded: suspend () -> Unit, + scope: CoroutineScope, + ): ReceiveChannel> { + var afterSubjectId: String? = null + var lastOffset = 0 + val channel = Channel>(CHANNEL_CAPACITY) + scope.launch(dispatcherIO) { + ranges + .forEach { range -> + require(lastOffset == range.first) { + "[loadBiometricIdentitiesPaged] The range start must match the last seen sample count. " + + "Expected: $lastOffset, Actual: ${range.first}" + } + val identities = loadBiometricIdentities( + query = query.copy(afterSubjectId = afterSubjectId), // update query with the last seen subject ID + pageSize = range.last - range.first, + format = format, + createIdentity = createIdentity, + onCandidateLoaded = onCandidateLoaded, + ) + afterSubjectId = identities.lastOrNull()?.let { + (it as? FaceIdentity)?.subjectId ?: (it as? FingerprintIdentity)?.subjectId + } + lastOffset = range.last + 1 + channel.send(identities) + } + channel.close() + } + return channel + } + + private suspend fun loadBiometricIdentities( + query: SubjectQuery, + pageSize: Int, + format: String?, + createIdentity: (subjectId: String, samples: List) -> T, + onCandidateLoaded: suspend () -> Unit, + ): List = withContext(dispatcherIO) { + requireNotNull(format) { "Appropriate sampleFormat is required for loading biometric identities." } + subjectDao + .loadSamples(queryBuilder.buildBiometricTemplatesQuery(query, pageSize)) + .map { (subjectId, templates) -> + onCandidateLoaded() + createIdentity(subjectId, templates) + } + } + + override suspend fun delete(queries: List) { + Simber.i("[delete] Deleting subjects with queries: $queries", tag = ROOM_RECORDS_DB) + database.withTransaction { + queries.forEach { query -> + subjectDao.deleteSubjects(queryBuilder.buildDeleteQuery(query)) + } + } + } + + override suspend fun deleteAll() { + Simber.i("[deleteAll] Deleting all subjects.", tag = ROOM_RECORDS_DB) + subjectDao.deleteSubjects(queryBuilder.buildDeleteQuery(SubjectQuery())) + } + + override suspend fun performActions( + actions: List, + project: Project, + ) { + database.withTransaction { + actions.forEach { action -> + Simber.d("[performActions] Performing action: $action", tag = ROOM_RECORDS_DB) + when (action) { + is SubjectAction.Creation -> createSubject(action.subject, project) + is SubjectAction.Update -> updateSubject(action) + is SubjectAction.Deletion -> deleteSubject(action.subjectId) + } + } + } + } + + private suspend fun createSubject( + subject: DomainSubject, + project: Project, + ) { + require(subject.faceSamples.isNotEmpty() || subject.fingerprintSamples.isNotEmpty()) { + val errorMsg = "Subject should include at least one of the face or fingerprint samples" + Simber.i( + "[createSubject] $errorMsg for subjectId: ${subject.subjectId}", + tag = ROOM_RECORDS_DB, + ) + errorMsg + } + val dbSubject = DbSubject( + subjectId = subject.subjectId, + projectId = subject.projectId, + attendantId = tokenizationProcessor + .tokenizeIfNecessary( + subject.attendantId, + TokenKeyType.AttendantId, + project, + ).value, + moduleId = tokenizationProcessor + .tokenizeIfNecessary( + subject.moduleId, + TokenKeyType.ModuleId, + project, + ).value, + createdAt = subject.createdAt?.time, + updatedAt = subject.updatedAt?.time, + ) + subjectDao.insertSubject(dbSubject) + subject.fingerprintSamples.takeIf { it.isNotEmpty() }?.let { samples -> + val dbFingerprints = samples.map { it.toRoomDb(subject.subjectId) } + subjectDao.insertBiometricSamples(dbFingerprints) + } + subject.faceSamples.takeIf { it.isNotEmpty() }?.let { samples -> + val dbFaces = samples.map { it.toRoomDb(subject.subjectId) } + subjectDao.insertBiometricSamples(dbFaces) + } + } + + private suspend fun updateSubject(action: SubjectAction.Update) { + val dbSubject = subjectDao.getSubject(action.subjectId) + if (dbSubject != null) { + val referencesToDelete = action.referenceIdsToRemove.toSet() + require( + referencesToDelete.size != dbSubject.biometricTemplates.size || + action.faceSamplesToAdd.isNotEmpty() || + action.fingerprintSamplesToAdd.isNotEmpty(), + ) { + val errorMsg = + "Cannot delete all samples for subject ${action.subjectId} without adding new ones" + Simber.i("[updateSubject] $errorMsg", tag = ROOM_RECORDS_DB) + errorMsg + } + dbSubject.biometricTemplates.filter { it.referenceId in referencesToDelete }.forEach { + subjectDao.deleteBiometricSample(it.uuid) + } + val templatesToAdd = + action.faceSamplesToAdd.map { it.toRoomDb(action.subjectId) } + + action.fingerprintSamplesToAdd.map { it.toRoomDb(action.subjectId) } + if (templatesToAdd.isNotEmpty()) { + subjectDao.insertBiometricSamples(templatesToAdd) + } + } else { + Simber.e( + "[updateSubject] Subject ${action.subjectId} not found for update", + IllegalStateException( + "Subject ${action.subjectId} not found for update", + ), + tag = ROOM_RECORDS_DB, + ) + } + } + + private suspend fun deleteSubject(subjectId: String) { + Simber.d("[deleteSubject] Deleting subject: $subjectId", tag = ROOM_RECORDS_DB) + subjectDao.deleteSubject(subjectId) + } +} diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordQueryBuilder.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordQueryBuilder.kt new file mode 100644 index 0000000000..61d7a07520 --- /dev/null +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordQueryBuilder.kt @@ -0,0 +1,158 @@ +package com.simprints.infra.enrolment.records.repository.local + +import androidx.sqlite.db.SimpleSQLiteQuery +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate.Companion.FORMAT_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate.Companion.TEMPLATE_TABLE_NAME +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.ATTENDANT_ID_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.MODULE_ID_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.PROJECT_ID_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.SUBJECT_ID_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.SUBJECT_TABLE_NAME +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ROOM_RECORDS_DB +import com.simprints.infra.logging.Simber +import javax.inject.Inject + +internal class RoomEnrolmentRecordQueryBuilder @Inject constructor() { + /** + * Builds a query to select subjects based on the provided [SubjectQuery]. + * The query will be on the `SUBJECT_TABLE_NAME` table and will include filtering criteria + * Don't set the format in the [SubjectQuery] for this method instead use [buildBiometricTemplatesQuery]. + * @param query The [SubjectQuery] containing the filtering criteria. + * @return A [SimpleSQLiteQuery] that can be executed against the database. + */ + fun buildSubjectQuery(query: SubjectQuery): SimpleSQLiteQuery { + // require format not to be set for subject query and guide to use the buildBiometricTemplatesQuery instead + require(query.fingerprintSampleFormat == null && query.faceSampleFormat == null) { + "Cannot set format for subject query, use buildBiometricTemplatesQuery instead" + } + val (whereClause, args) = buildWhereClause(query) + val orderByClause = buildOrderByClause(query) + val sql = + """ + SELECT * FROM $SUBJECT_TABLE_NAME S + $whereClause + $orderByClause + """.trimIndent() + return SimpleSQLiteQuery(sql, args.toTypedArray()) + } + + fun buildCountQuery(query: SubjectQuery): SimpleSQLiteQuery { + val (whereClause, args) = buildWhereClause(query) + val specificFormat = query.fingerprintSampleFormat ?: query.faceSampleFormat + + val sql = if (specificFormat != null) { + "SELECT COUNT(DISTINCT S.$SUBJECT_ID_COLUMN) FROM $SUBJECT_TABLE_NAME S INNER JOIN $TEMPLATE_TABLE_NAME T" + + " using(subjectId) $whereClause" + } else { + "SELECT COUNT(DISTINCT S.$SUBJECT_ID_COLUMN) FROM $SUBJECT_TABLE_NAME S $whereClause" + } + return SimpleSQLiteQuery(sql, args.toTypedArray()) + } + + fun buildBiometricTemplatesQuery( + query: SubjectQuery, + pageSize: Int, + ): SimpleSQLiteQuery { + // require format to be set for biometric templates query + val format = query.fingerprintSampleFormat ?: query.faceSampleFormat + require(format != null) { + "Must set format for biometric templates query, use buildSubjectQuery or buildCountQuery instead" + } + val updatedQuery = query.copy(sort = true) + val (whereClause, args) = buildWhereClause(updatedQuery) + val orderByClause = buildOrderByClause(updatedQuery) + val sql = + """ + SELECT A.* + FROM $TEMPLATE_TABLE_NAME A + INNER JOIN ( + SELECT distinct S.subjectId + FROM $SUBJECT_TABLE_NAME S INNER JOIN $TEMPLATE_TABLE_NAME T + USING(subjectId) + $whereClause + $orderByClause + LIMIT $pageSize + ) B USING(subjectId) where A.format ='$format' + """.trimIndent() + return SimpleSQLiteQuery(sql, args.toTypedArray()) + } + + fun buildDeleteQuery(query: SubjectQuery): SimpleSQLiteQuery { + require(query.faceSampleFormat == null && query.fingerprintSampleFormat == null) { + val errorMsg = "faceSampleFormat and fingerprintSampleFormat are not supported for deletion" + Simber.i("[delete] $errorMsg", tag = ROOM_RECORDS_DB) + errorMsg + } + val (whereClause, args) = buildWhereClause( + query, + subjectAlias = "", + templateAlias = "", + ) + val sql = "DELETE FROM DbSubject $whereClause" + return SimpleSQLiteQuery(sql, args.toTypedArray()) + } + + private fun buildWhereClause( + query: SubjectQuery, + subjectAlias: String = "S.", // Default alias for subject table, dot included. Empty string for no alias. + templateAlias: String = "T.", // Default alias for template table, dot included. Empty string for no alias. + ): Pair> { + val clauses = mutableListOf() + val args = mutableListOf() + require(!(query.fingerprintSampleFormat != null && query.faceSampleFormat != null)) { + "Cannot set both fingerprintSampleFormat and faceSampleFormat" + } + // to achieve the highest performance, we should not use OR in the where clause + // subject id params are mutually exclusive, so only one of them will be set at a time + when { + query.subjectId != null -> { + clauses.add("${subjectAlias}$SUBJECT_ID_COLUMN = ?") + args.add(query.subjectId) + } + + query.subjectIds?.isNotEmpty() == true -> { + clauses.add("${subjectAlias}$SUBJECT_ID_COLUMN IN (${query.subjectIds.joinToString(",") { "?" }})") + args.addAll(query.subjectIds) + } + + query.afterSubjectId != null -> { + clauses.add("${subjectAlias}$SUBJECT_ID_COLUMN > ?") + args.add(query.afterSubjectId) + } + } + + query.projectId?.let { + clauses.add("${subjectAlias}$PROJECT_ID_COLUMN = ?") + args.add(it) + } + query.attendantId?.let { + clauses.add("${subjectAlias}$ATTENDANT_ID_COLUMN = ?") + args.add(it.value) + } + query.moduleId?.let { + clauses.add("${subjectAlias}$MODULE_ID_COLUMN = ?") + args.add(it.value) + } + query.faceSampleFormat?.let { + clauses.add("${templateAlias}$FORMAT_COLUMN = ?") + args.add(it) + } + query.fingerprintSampleFormat?.let { + clauses.add("${templateAlias}$FORMAT_COLUMN = ?") + args.add(it) + } + + var whereClauseResult = if (clauses.isNotEmpty()) "WHERE ${clauses.joinToString(" AND ")}" else "" + return Pair(whereClauseResult, args) + } + + private fun buildOrderByClause( + query: SubjectQuery, + subjectAlias: String = "S.", + ) = if (query.sort) { + "ORDER BY $subjectAlias$SUBJECT_ID_COLUMN ASC" + } else { + "" + } +} diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/SelectEnrolmentRecordLocalDataSourceUseCase.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/SelectEnrolmentRecordLocalDataSourceUseCase.kt new file mode 100644 index 0000000000..7c4c1a6f22 --- /dev/null +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/SelectEnrolmentRecordLocalDataSourceUseCase.kt @@ -0,0 +1,13 @@ +package com.simprints.infra.enrolment.records.repository.local + +import javax.inject.Inject + +internal class SelectEnrolmentRecordLocalDataSourceUseCase @Inject constructor( + private val roomDataSource: RoomEnrolmentRecordLocalDataSource, + private val realmDataSource: RealmEnrolmentRecordLocalDataSource, +) { + operator fun invoke(): EnrolmentRecordLocalDataSource { + // Todo later we will add logic to select the data source + return realmDataSource + } +} diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/IFingerIdentifier.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/IFingerIdentifier.kt new file mode 100644 index 0000000000..5f7beec9d2 --- /dev/null +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/IFingerIdentifier.kt @@ -0,0 +1,50 @@ +package com.simprints.infra.enrolment.records.repository.local.models +import com.simprints.core.domain.fingerprint.IFingerIdentifier as CoreFingerIdentifier + +enum class IFingerIdentifier( + val id: Int, +) { + RIGHT_5TH_FINGER(0), + RIGHT_4TH_FINGER(1), + RIGHT_3RD_FINGER(2), + RIGHT_INDEX_FINGER(3), + RIGHT_THUMB(4), + LEFT_THUMB(5), + LEFT_INDEX_FINGER(6), + LEFT_3RD_FINGER(7), + LEFT_4TH_FINGER(8), + LEFT_5TH_FINGER(9), + ; + + companion object { + fun fromId(id: Int) = IFingerIdentifier.entries + .firstOrNull { it.id == id } + ?: throw IllegalArgumentException("Invalid id: $id") + } +} + +internal fun IFingerIdentifier.toDomain() = when (this) { + IFingerIdentifier.RIGHT_5TH_FINGER -> CoreFingerIdentifier.RIGHT_5TH_FINGER + IFingerIdentifier.RIGHT_4TH_FINGER -> CoreFingerIdentifier.RIGHT_4TH_FINGER + IFingerIdentifier.RIGHT_3RD_FINGER -> CoreFingerIdentifier.RIGHT_3RD_FINGER + IFingerIdentifier.RIGHT_INDEX_FINGER -> CoreFingerIdentifier.RIGHT_INDEX_FINGER + IFingerIdentifier.RIGHT_THUMB -> CoreFingerIdentifier.RIGHT_THUMB + IFingerIdentifier.LEFT_THUMB -> CoreFingerIdentifier.LEFT_THUMB + IFingerIdentifier.LEFT_INDEX_FINGER -> CoreFingerIdentifier.LEFT_INDEX_FINGER + IFingerIdentifier.LEFT_3RD_FINGER -> CoreFingerIdentifier.LEFT_3RD_FINGER + IFingerIdentifier.LEFT_4TH_FINGER -> CoreFingerIdentifier.LEFT_4TH_FINGER + IFingerIdentifier.LEFT_5TH_FINGER -> CoreFingerIdentifier.LEFT_5TH_FINGER +} + +internal fun CoreFingerIdentifier.fromDomain() = when (this) { + CoreFingerIdentifier.RIGHT_5TH_FINGER -> IFingerIdentifier.RIGHT_5TH_FINGER + CoreFingerIdentifier.RIGHT_4TH_FINGER -> IFingerIdentifier.RIGHT_4TH_FINGER + CoreFingerIdentifier.RIGHT_3RD_FINGER -> IFingerIdentifier.RIGHT_3RD_FINGER + CoreFingerIdentifier.RIGHT_INDEX_FINGER -> IFingerIdentifier.RIGHT_INDEX_FINGER + CoreFingerIdentifier.RIGHT_THUMB -> IFingerIdentifier.RIGHT_THUMB + CoreFingerIdentifier.LEFT_THUMB -> IFingerIdentifier.LEFT_THUMB + CoreFingerIdentifier.LEFT_INDEX_FINGER -> IFingerIdentifier.LEFT_INDEX_FINGER + CoreFingerIdentifier.LEFT_3RD_FINGER -> IFingerIdentifier.LEFT_3RD_FINGER + CoreFingerIdentifier.LEFT_4TH_FINGER -> IFingerIdentifier.LEFT_4TH_FINGER + CoreFingerIdentifier.LEFT_5TH_FINGER -> IFingerIdentifier.LEFT_5TH_FINGER +} diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbFaceSample.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmFaceSampleConverter.kt similarity index 67% rename from infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbFaceSample.kt rename to infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmFaceSampleConverter.kt index 00ef14df71..1cab7429a8 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbFaceSample.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmFaceSampleConverter.kt @@ -1,16 +1,16 @@ package com.simprints.infra.enrolment.records.repository.local.models import com.simprints.core.domain.face.FaceSample -import com.simprints.infra.enrolment.records.realm.store.models.DbFaceSample +import com.simprints.infra.enrolment.records.realm.store.models.DbFaceSample as RealmFaceSample -internal fun DbFaceSample.fromDbToDomain(): FaceSample = FaceSample( +internal fun RealmFaceSample.toDomain(): FaceSample = FaceSample( id = id, template = template, format = format, referenceId = referenceId, ) -internal fun FaceSample.fromDomainToDb(): DbFaceSample = DbFaceSample().also { sample -> +internal fun FaceSample.toRealmDb(): RealmFaceSample = RealmFaceSample().also { sample -> sample.id = id sample.referenceId = referenceId sample.template = template diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbFingerprintSample.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmFingerprintSampleConverter.kt similarity index 50% rename from infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbFingerprintSample.kt rename to infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmFingerprintSampleConverter.kt index 9b81bf369a..6b2be097ba 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbFingerprintSample.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmFingerprintSampleConverter.kt @@ -1,21 +1,20 @@ package com.simprints.infra.enrolment.records.repository.local.models import com.simprints.core.domain.fingerprint.FingerprintSample -import com.simprints.core.domain.fingerprint.IFingerIdentifier -import com.simprints.infra.enrolment.records.realm.store.models.DbFingerprintSample +import com.simprints.infra.enrolment.records.realm.store.models.DbFingerprintSample as RealmFingerprintSample -internal fun DbFingerprintSample.fromDbToDomain(): FingerprintSample = FingerprintSample( +internal fun RealmFingerprintSample.toDomain(): FingerprintSample = FingerprintSample( id = id, - fingerIdentifier = IFingerIdentifier.entries[fingerIdentifier], + fingerIdentifier = IFingerIdentifier.fromId(fingerIdentifier).toDomain(), template = template, format = format, referenceId = referenceId, ) -internal fun FingerprintSample.fromDomainToDb(): DbFingerprintSample = DbFingerprintSample().also { sample -> +internal fun FingerprintSample.toRealmDb(): RealmFingerprintSample = RealmFingerprintSample().also { sample -> sample.id = id sample.referenceId = referenceId - sample.fingerIdentifier = fingerIdentifier.ordinal + sample.fingerIdentifier = fingerIdentifier.fromDomain().id sample.template = template sample.format = format } diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbSubject.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmSubjectConverter.kt similarity index 82% rename from infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbSubject.kt rename to infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmSubjectConverter.kt index 7cace06d8c..cb93aa32b7 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/DbSubject.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RealmSubjectConverter.kt @@ -7,14 +7,14 @@ import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.domain.tokenization.isTokenized import com.simprints.infra.enrolment.records.realm.store.models.DbFaceSample import com.simprints.infra.enrolment.records.realm.store.models.DbFingerprintSample -import com.simprints.infra.enrolment.records.realm.store.models.DbSubject import com.simprints.infra.enrolment.records.realm.store.models.toDate import com.simprints.infra.enrolment.records.realm.store.models.toRealmInstant import com.simprints.infra.enrolment.records.repository.domain.models.Subject import io.realm.kotlin.ext.toRealmList import io.realm.kotlin.types.RealmUUID +import com.simprints.infra.enrolment.records.realm.store.models.DbSubject as RealmSubject -internal fun DbSubject.fromDbToDomain(): Subject { +internal fun RealmSubject.toDomain(): Subject { val attendantId = if (isAttendantIdTokenized) attendantId.asTokenizableEncrypted() else attendantId.asTokenizableRaw() val moduleId = @@ -27,12 +27,12 @@ internal fun DbSubject.fromDbToDomain(): Subject { moduleId = moduleId, createdAt = createdAt?.toDate(), updatedAt = updatedAt?.toDate(), - fingerprintSamples = fingerprintSamples.map(DbFingerprintSample::fromDbToDomain), - faceSamples = faceSamples.map(DbFaceSample::fromDbToDomain), + fingerprintSamples = fingerprintSamples.map(DbFingerprintSample::toDomain), + faceSamples = faceSamples.map(DbFaceSample::toDomain), ) } -internal fun Subject.fromDomainToDb(): DbSubject = DbSubject().also { subject -> +internal fun Subject.toRealmDb(): RealmSubject = RealmSubject().also { subject -> subject.subjectId = RealmUUID.from(subjectId) subject.projectId = projectId subject.attendantId = attendantId.value @@ -40,8 +40,8 @@ internal fun Subject.fromDomainToDb(): DbSubject = DbSubject().also { subject -> subject.createdAt = createdAt?.toRealmInstant() subject.updatedAt = updatedAt?.toRealmInstant() subject.fingerprintSamples = - fingerprintSamples.map(FingerprintSample::fromDomainToDb).toRealmList() - subject.faceSamples = faceSamples.map(FaceSample::fromDomainToDb).toRealmList() + fingerprintSamples.map(FingerprintSample::toRealmDb).toRealmList() + subject.faceSamples = faceSamples.map(FaceSample::toRealmDb).toRealmList() subject.isModuleIdTokenized = moduleId.isTokenized() subject.isAttendantIdTokenized = attendantId.isTokenized() } diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RoomBiometricTemplateConverter.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RoomBiometricTemplateConverter.kt new file mode 100644 index 0000000000..f7e7954f34 --- /dev/null +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RoomBiometricTemplateConverter.kt @@ -0,0 +1,40 @@ +package com.simprints.infra.enrolment.records.repository.local.models + +import com.simprints.core.domain.face.FaceSample +import com.simprints.core.domain.fingerprint.FingerprintSample +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate +import com.simprints.infra.enrolment.records.room.store.models.Modality + +internal fun DbBiometricTemplate.toFingerprintSample(): FingerprintSample = FingerprintSample( + id = uuid, + fingerIdentifier = IFingerIdentifier.fromId(identifier!!).toDomain(), + template = templateData, + format = format, + referenceId = referenceId, +) + +internal fun DbBiometricTemplate.toFaceSample(): FaceSample = FaceSample( + id = uuid, + template = templateData, + format = format, + referenceId = referenceId, +) + +internal fun FaceSample.toRoomDb(subjectId: String): DbBiometricTemplate = DbBiometricTemplate( + uuid = id, + templateData = template, + format = format, + subjectId = subjectId, + referenceId = referenceId, + modality = Modality.FACE.id, +) + +internal fun FingerprintSample.toRoomDb(subjectId: String) = DbBiometricTemplate( + uuid = id, + identifier = fingerIdentifier.fromDomain().id, + templateData = template, + format = format, + subjectId = subjectId, + referenceId = referenceId, + modality = Modality.FINGERPRINT.id, +) diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RoomSubjectConverter.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RoomSubjectConverter.kt new file mode 100644 index 0000000000..385e341830 --- /dev/null +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/models/RoomSubjectConverter.kt @@ -0,0 +1,20 @@ +package com.simprints.infra.enrolment.records.repository.local.models + +import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.infra.enrolment.records.repository.domain.models.Subject +import com.simprints.infra.enrolment.records.room.store.models.Modality +import com.simprints.infra.enrolment.records.room.store.models.SubjectBiometrics +import java.util.Date + +internal fun SubjectBiometrics.toDomain() = Subject( + subjectId = subject.subjectId.toString(), + projectId = subject.projectId, + attendantId = subject.attendantId.asTokenizableEncrypted(), + moduleId = subject.moduleId.asTokenizableEncrypted(), + createdAt = subject.createdAt?.toDate(), + updatedAt = subject.updatedAt?.toDate(), + fingerprintSamples = biometricTemplates.filter { it.modality == Modality.FINGERPRINT.id }.map { it.toFingerprintSample() }, + faceSamples = biometricTemplates.filter { it.modality == Modality.FACE.id }.map { it.toFaceSample() }, +) + +fun Long.toDate() = Date(this) diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImplTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImplTest.kt index ff0ba31bbc..ae5d863b64 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImplTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImplTest.kt @@ -14,6 +14,7 @@ import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.enrolment.records.repository.local.EnrolmentRecordLocalDataSource +import com.simprints.infra.enrolment.records.repository.local.SelectEnrolmentRecordLocalDataSourceUseCase import com.simprints.infra.enrolment.records.repository.remote.EnrolmentRecordRemoteDataSource import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -55,6 +56,7 @@ class EnrolmentRecordRepositoryImplTest { private val onCandidateLoaded: () -> Unit = {} private val tokenizationProcessor = mockk() private val localDataSource = mockk(relaxed = true) + private val selectEnrolmentRecordLocalDataSource = mockk() private val commCareDataSource = mockk(relaxed = true) private val remoteDataSource = mockk(relaxed = true) private val prefsEditor = mockk(relaxed = true) @@ -72,11 +74,11 @@ class EnrolmentRecordRepositoryImplTest { fun setup() { every { prefsEditor.putString(any(), any()) } returns prefsEditor every { prefsEditor.remove(any()) } returns prefsEditor - + every { selectEnrolmentRecordLocalDataSource() } returns localDataSource repository = EnrolmentRecordRepositoryImpl( context = ctx, remoteDataSource = remoteDataSource, - localDataSource = localDataSource, + selectEnrolmentRecordLocalDataSource = selectEnrolmentRecordLocalDataSource, commCareDataSource = commCareDataSource, tokenizationProcessor = tokenizationProcessor, dispatcher = UnconfinedTestDispatcher(), @@ -390,12 +392,7 @@ class EnrolmentRecordRepositoryImplTest { assert(faceIdentities == expectedFaceIdentities) coVerify(exactly = 1) { - localDataSource.loadFaceIdentities( - expectedSubjectQuery, - expectedRange, - any(), - project, - this@runTest, + localDataSource.loadFaceIdentities(expectedSubjectQuery, expectedRange, any(), project,this@runTest, onCandidateLoaded, ) } diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/EnrolmentRecordLocalDataSourceImplTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceTest.kt similarity index 90% rename from infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/EnrolmentRecordLocalDataSourceImplTest.kt rename to infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceTest.kt index b855586d02..20df337b8e 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/EnrolmentRecordLocalDataSourceImplTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceTest.kt @@ -18,11 +18,11 @@ import com.simprints.infra.enrolment.records.repository.domain.models.Fingerprin import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery -import com.simprints.infra.enrolment.records.repository.local.EnrolmentRecordLocalDataSourceImpl.Companion.FACE_SAMPLES_FIELD -import com.simprints.infra.enrolment.records.repository.local.EnrolmentRecordLocalDataSourceImpl.Companion.FINGERPRINT_SAMPLES_FIELD -import com.simprints.infra.enrolment.records.repository.local.EnrolmentRecordLocalDataSourceImpl.Companion.FORMAT_FIELD -import com.simprints.infra.enrolment.records.repository.local.models.fromDbToDomain -import com.simprints.infra.enrolment.records.repository.local.models.fromDomainToDb +import com.simprints.infra.enrolment.records.repository.local.RealmEnrolmentRecordLocalDataSource.Companion.FACE_SAMPLES_FIELD +import com.simprints.infra.enrolment.records.repository.local.RealmEnrolmentRecordLocalDataSource.Companion.FINGERPRINT_SAMPLES_FIELD +import com.simprints.infra.enrolment.records.repository.local.RealmEnrolmentRecordLocalDataSource.Companion.FORMAT_FIELD +import com.simprints.infra.enrolment.records.repository.local.models.toDomain +import com.simprints.infra.enrolment.records.repository.local.models.toRealmDb import io.mockk.* import io.mockk.impl.annotations.MockK import io.realm.kotlin.MutableRealm @@ -31,6 +31,7 @@ import io.realm.kotlin.query.RealmQuery import io.realm.kotlin.query.RealmSingleQuery import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before @@ -38,7 +39,7 @@ import org.junit.Test import java.util.UUID import kotlin.random.Random -class EnrolmentRecordLocalDataSourceImplTest { +class RealmEnrolmentRecordLocalDataSourceTest { @MockK private lateinit var realm: Realm @@ -60,9 +61,9 @@ class EnrolmentRecordLocalDataSourceImplTest { @MockK private lateinit var project: Project - private lateinit var blockCapture: CapturingSlot<(Realm) -> Any> + private lateinit var blockCapture: CapturingSlot Any> private lateinit var mutableBlockCapture: CapturingSlot<(MutableRealm) -> Any> - private val onCandidateLoaded: () -> Unit = {} + private val onCandidateLoaded: suspend () -> Unit = {} private var localSubjects: MutableList = mutableListOf() private lateinit var enrolmentRecordLocalDataSource: EnrolmentRecordLocalDataSource @@ -77,13 +78,13 @@ class EnrolmentRecordLocalDataSourceImplTest { every { mutableRealm.delete(any()) } answers { localSubjects.clear() } every { mutableRealm.deleteAll() } answers { localSubjects.clear() } every { mutableRealm.copyToRealm(capture(insertedSubject), any()) } answers { - localSubjects.add(insertedSubject.captured.fromDbToDomain()) + localSubjects.add(insertedSubject.captured.toDomain()) insertedSubject.captured } blockCapture = slot() coEvery { realmWrapperMock.readRealm(capture(blockCapture)) } answers { - blockCapture.captured.invoke(realm) + runBlocking { blockCapture.captured.invoke(realm) } } mutableBlockCapture = slot() coEvery { realmWrapperMock.writeRealm(capture(mutableBlockCapture)) } answers { @@ -99,7 +100,7 @@ class EnrolmentRecordLocalDataSourceImplTest { every { realmQuery.query(any(), any()) } returns realmQuery every { realmQuery.first() } returns realmSingleQuery - enrolmentRecordLocalDataSource = EnrolmentRecordLocalDataSourceImpl( + enrolmentRecordLocalDataSource = RealmEnrolmentRecordLocalDataSource( realmWrapperMock, tokenizationProcessor, UnconfinedTestDispatcher(), @@ -133,7 +134,7 @@ class EnrolmentRecordLocalDataSourceImplTest { @Test fun givenValidSerializableQueryForFingerprints_loadIsCalled() = runTest { val savedPersons = saveFakePeople(getRandomPeople(20)) - val fakePerson = savedPersons[0].fromDomainToDb() + val fakePerson = savedPersons[0].toRealmDb() val people = mutableListOf() enrolmentRecordLocalDataSource @@ -197,7 +198,7 @@ class EnrolmentRecordLocalDataSourceImplTest { @Test fun givenValidSerializableQueryForFace_loadIsCalled() = runTest { val savedPersons = saveFakePeople(getRandomPeople(20)) - val fakePerson = savedPersons[0].fromDomainToDb() + val fakePerson = savedPersons[0].toRealmDb() val people = mutableListOf() enrolmentRecordLocalDataSource @@ -224,29 +225,29 @@ class EnrolmentRecordLocalDataSourceImplTest { val people = enrolmentRecordLocalDataSource.load(SubjectQuery()).toList() listOf(fakePerson).zip(people).forEach { (dbSubject, subject) -> - assertThat(dbSubject.deepEquals(subject.fromDomainToDb())).isTrue() + assertThat(dbSubject.deepEquals(subject.toRealmDb())).isTrue() } } @Test fun givenManyPeopleSaved_loadByUserIdShouldReturnTheRightPeople() = runTest { val savedPersons = saveFakePeople(getRandomPeople(20)) - val fakePerson = savedPersons[0].fromDomainToDb() + val fakePerson = savedPersons[0].toRealmDb() val people = enrolmentRecordLocalDataSource.load(SubjectQuery(attendantId = savedPersons[0].attendantId)).toList() listOf(fakePerson).zip(people).forEach { (dbSubject, subject) -> - assertThat(dbSubject.deepEquals(subject.fromDomainToDb())).isTrue() + assertThat(dbSubject.deepEquals(subject.toRealmDb())).isTrue() } } @Test fun givenManyPeopleSaved_loadByModuleIdShouldReturnTheRightPeople() = runTest { val savedPersons = saveFakePeople(getRandomPeople(20)) - val fakePerson = savedPersons[0].fromDomainToDb() + val fakePerson = savedPersons[0].toRealmDb() val people = enrolmentRecordLocalDataSource.load(SubjectQuery(moduleId = fakePerson.moduleId.asTokenizableEncrypted())).toList() listOf(fakePerson).zip(people).forEach { (dbSubject, subject) -> - assertThat(dbSubject.deepEquals(subject.fromDomainToDb())).isTrue() + assertThat(dbSubject.deepEquals(subject.toRealmDb())).isTrue() } } @@ -256,7 +257,7 @@ class EnrolmentRecordLocalDataSourceImplTest { every { realmSingleQuery.find() } returns null enrolmentRecordLocalDataSource.performActions( - listOf(SubjectAction.Creation(subject.fromDbToDomain())), + listOf(SubjectAction.Creation(subject.toDomain())), project, ) val peopleCount = enrolmentRecordLocalDataSource.count() @@ -273,11 +274,11 @@ class EnrolmentRecordLocalDataSourceImplTest { fingerprintSamples = listOf( getRandomFingerprintSample("fingerToDelete"), ), - ).fromDomainToDb() + ).toRealmDb() val subject = getFakePerson() enrolmentRecordLocalDataSource.performActions( - listOf(SubjectAction.Creation(subject.fromDbToDomain())), + listOf(SubjectAction.Creation(subject.toDomain())), project, ) @@ -301,7 +302,7 @@ class EnrolmentRecordLocalDataSourceImplTest { getRandomFingerprintSample(referenceId = "fingerToDelete"), getRandomFingerprintSample(), ), - ).fromDomainToDb() + ).toRealmDb() enrolmentRecordLocalDataSource.performActions( listOf( @@ -366,9 +367,9 @@ class EnrolmentRecordLocalDataSourceImplTest { assertThat(peopleCount).isEqualTo(0) } - private fun getFakePerson(): DbSubject = getRandomSubject().fromDomainToDb() + private fun getFakePerson(): DbSubject = getRandomSubject().toRealmDb() - private fun saveFakePerson(fakeSubject: DbSubject): DbSubject = fakeSubject.also { localSubjects.add(it.fromDbToDomain()) } + private fun saveFakePerson(fakeSubject: DbSubject): DbSubject = fakeSubject.also { localSubjects.add(it.toDomain()) } private fun saveFakePeople(subjects: List): List = subjects.toMutableList().also { localSubjects.addAll(it) } 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 new file mode 100644 index 0000000000..4aebfd57e7 --- /dev/null +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt @@ -0,0 +1,1534 @@ +package com.simprints.infra.enrolment.records.repository.local + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.face.FaceSample +import com.simprints.core.domain.fingerprint.FingerprintSample +import com.simprints.core.domain.fingerprint.IFingerIdentifier +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.enrolment.records.repository.domain.models.BiometricDataSource.Simprints +import com.simprints.infra.enrolment.records.repository.domain.models.Subject +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery +import com.simprints.infra.enrolment.records.room.store.SubjectsDatabaseFactory +import com.simprints.infra.security.keyprovider.LocalDbKey +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.channels.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class RoomEnrolmentRecordLocalDataSourceTest { + companion object { + const val PROJECT_1_ID = "project1" + const val PROJECT_2_ID = "project2" + val ATTENDANT_1_ID = "attendant1".asTokenizableEncrypted() + val ATTENDANT_2_ID = "attendant2".asTokenizableEncrypted() + val MODULE_1_ID = "module1".asTokenizableEncrypted() + val MODULE_2_ID = "module2".asTokenizableEncrypted() + val MODULE_3_ID = "module3".asTokenizableEncrypted() + + const val ROC_1_FORMAT = "roc_123" + const val ROC_3_FORMAT = "roc_3" + const val NEC_FORMAT = "nec" + const val ISO_FORMAT = "iso" + const val UNUSED_FORMAT = "unused" + } + + // Mocks and setup remain the same + private lateinit var dataSource: EnrolmentRecordLocalDataSource + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + private val subjectDatabaseFactory = SubjectsDatabaseFactory( + ApplicationProvider.getApplicationContext(), + mockk { + every { getLocalDbKeyOrThrow(any()) } returns LocalDbKey("any", byteArrayOf(1, 2, 3)) + }, + ) + + // --- Test Data --- + private val date = Date() // Use a fixed date for consistent timestamps in tests + + // Samples defined first + private val faceSample1 = FaceSample( + template = byteArrayOf(1, 2, 3), + format = ROC_1_FORMAT, + referenceId = "ref-face-1", + id = "face-uuid-1", + ) + private val faceSample2 = FaceSample( + template = byteArrayOf(4, 5, 6), + format = ROC_3_FORMAT, + referenceId = "ref-face-2", + id = "face-uuid-2", + ) + private val faceSample3 = FaceSample( + template = byteArrayOf(7, 8, 9), + format = ROC_1_FORMAT, + referenceId = "ref-face-3-p2", + id = "face-uuid-3-p2", + ) + private val fingerprintSample1 = FingerprintSample( + fingerIdentifier = IFingerIdentifier.LEFT_THUMB, + template = byteArrayOf(10, 11), + format = NEC_FORMAT, + referenceId = "ref-fp-1", + id = "fp-uuid-1", + ) + private val fingerprintSample2 = FingerprintSample( + fingerIdentifier = IFingerIdentifier.RIGHT_THUMB, + template = byteArrayOf(12, 13), + format = ISO_FORMAT, + referenceId = "ref-fp-2", + id = "fp-uuid-2", + ) + private val fingerprintSample3 = FingerprintSample( + fingerIdentifier = IFingerIdentifier.LEFT_INDEX_FINGER, + template = byteArrayOf(14, 15), + format = NEC_FORMAT, + referenceId = "ref-fp-3-p2", + id = "fp-uuid-3-p2", + ) + + // Subjects defined using the samples + private val subject1P1WithFace = Subject( + subjectId = "subj-001", + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_1_ID, + faceSamples = listOf(faceSample1), + fingerprintSamples = emptyList(), + createdAt = date, + updatedAt = date, + ) + private val subject2P1WithFinger = Subject( + subjectId = "subj-002", + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_1_ID, + faceSamples = emptyList(), + fingerprintSamples = listOf(fingerprintSample1), + createdAt = date, + updatedAt = date, + ) + private val subject3P1WithBoth = Subject( + subjectId = "subj-003", + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_2_ID, + faceSamples = listOf(faceSample2), + fingerprintSamples = listOf(fingerprintSample2), + ) + private val subject4P2WithBoth = Subject( + subjectId = "subj-004", + projectId = PROJECT_2_ID, + attendantId = ATTENDANT_2_ID, + moduleId = MODULE_2_ID, + faceSamples = listOf(faceSample3), + fingerprintSamples = listOf(fingerprintSample3), + createdAt = date, + updatedAt = date, + ) + private val subject5P2WithFace = Subject( + // Added subject + subjectId = "subj-005", + projectId = PROJECT_2_ID, + attendantId = ATTENDANT_2_ID, + moduleId = MODULE_3_ID, + faceSamples = listOf(faceSample3.copy(id = UUID.randomUUID().toString())), + fingerprintSamples = emptyList(), + createdAt = Date(date.time + 1000), // Slightly different time + updatedAt = Date(date.time + 1000), + ) + private val subject6P2WithFinger = Subject( + // Added subject + subjectId = "subj-006", + projectId = PROJECT_2_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_3_ID, + faceSamples = emptyList(), + fingerprintSamples = listOf(fingerprintSample3.copy(id = UUID.randomUUID().toString())), + createdAt = Date(date.time + 2000), // Different time + updatedAt = Date(date.time + 2000), + ) + private val subjectInvalidNoSamples = Subject( + subjectId = "subj-invalid", + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_1_ID, + createdAt = date, + updatedAt = date, + ) + + private val project: Project = mockk() + private lateinit var mockCallback: () -> Unit + + @Before + fun setUp() { + dataSource = RoomEnrolmentRecordLocalDataSource( + subjectDatabaseFactory, + mockk { + every { tokenizeIfNecessary(any(), any(), any()) } answers { + val arg1 = it.invocation.args[0] as TokenizableString + arg1 + } + }, + queryBuilder = RoomEnrolmentRecordQueryBuilder(), + dispatcherIO = testCoroutineRule.testCoroutineDispatcher, + ) + mockCallback = mockk(relaxed = true) // Relaxed mock to avoid specifying return values + every { project.id } returns PROJECT_1_ID // Basic mock setup for project ID + } + + @After + fun tearDown() { + runBlocking { + dataSource.deleteAll() + subjectDatabaseFactory.get().close() // Close the database connection + } + } + + val initialData = listOf( + SubjectAction.Creation(subject1P1WithFace), + SubjectAction.Creation(subject2P1WithFinger), + SubjectAction.Creation(subject3P1WithBoth), + SubjectAction.Creation(subject4P2WithBoth), + SubjectAction.Creation(subject5P2WithFace), + SubjectAction.Creation(subject6P2WithFinger), + ) + + private suspend fun setupInitialData() { + dataSource.performActions( + initialData, + project, + ) + } + + // --- Test Cases --- + + @Test + fun `performActions - Creation - should succeed with face sample`() = runTest { + // Given + val action = SubjectAction.Creation(subject1P1WithFace) + + // When + dataSource.performActions(listOf(action), project) + + // Then + val loaded = dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)) + assertThat(loaded).hasSize(1) + val createdSubject = loaded[0] + assertThat(createdSubject.subjectId).isEqualTo(subject1P1WithFace.subjectId) + assertThat(createdSubject.faceSamples).hasSize(1) + assertThat(createdSubject.faceSamples).containsExactly(faceSample1) + assertThat(createdSubject.fingerprintSamples).isEmpty() + assertThat(createdSubject.createdAt).isNotNull() + assertThat(createdSubject.updatedAt).isNotNull() + } + + @Test + fun `performActions - Creation - should succeed with fingerprint sample`() = runTest { + // Given + val action = SubjectAction.Creation(subject2P1WithFinger) + + // When + dataSource.performActions(listOf(action), project) + + // Then + val loaded = dataSource.load(SubjectQuery(subjectId = subject2P1WithFinger.subjectId)) + assertThat(loaded).hasSize(1) + val createdSubject = loaded[0] + assertThat(createdSubject.subjectId).isEqualTo(subject2P1WithFinger.subjectId) + assertThat(createdSubject.faceSamples).isEmpty() + assertThat(createdSubject.fingerprintSamples).hasSize(1) + assertThat(createdSubject.fingerprintSamples).containsExactly(fingerprintSample1) + assertThat(createdSubject.createdAt).isNotNull() + assertThat(createdSubject.updatedAt).isNotNull() + } + + @Test + fun `performActions - Creation - should succeed with both samples`() = runTest { + // Given: + val action = SubjectAction.Creation(subject3P1WithBoth) + + // When + dataSource.performActions(listOf(action), project) + + // Then + val loaded = dataSource.load(SubjectQuery(subjectId = subject3P1WithBoth.subjectId)) + assertThat(loaded).hasSize(1) + val createdSubject = loaded[0] + assertThat(createdSubject.subjectId).isEqualTo(subject3P1WithBoth.subjectId) + assertThat(createdSubject.faceSamples).hasSize(1) + assertThat(createdSubject.faceSamples).containsExactly(faceSample2) + assertThat(createdSubject.fingerprintSamples).hasSize(1) + assertThat(createdSubject.fingerprintSamples).containsExactly(fingerprintSample2) + } + + @Test(expected = IllegalArgumentException::class) // Reverted to JUnit exception check + fun `performActions - Creation - should fail without any samples`() = runTest { + // Given + val action = SubjectAction.Creation(subjectInvalidNoSamples) // Subject without samples + + // When + dataSource.performActions(listOf(action), project) // This line will throw + + // Then: Handled by expected exception. + } + + @Test + fun `performActions - Creation - should create multiple valid subjects`() = runTest { + // Given + val actions = listOf( + SubjectAction.Creation(subject1P1WithFace), + SubjectAction.Creation(subject2P1WithFinger), + SubjectAction.Creation(subject4P2WithBoth), // Belongs to Project 2 + ) + + // When + dataSource.performActions(actions, project) + + // Then + val loadedAllP1 = dataSource.load(SubjectQuery(projectId = PROJECT_1_ID, sort = true)) + val loadedAllP2 = dataSource.load(SubjectQuery(projectId = PROJECT_2_ID, sort = true)) + + assertThat(loadedAllP1).hasSize(2) + assertThat(loadedAllP1.map { it.subjectId }) + .containsExactly( + subject1P1WithFace.subjectId, + subject2P1WithFinger.subjectId, + ).inOrder() + + assertThat(loadedAllP2).hasSize(1) + assertThat(loadedAllP2[0].subjectId).isEqualTo(subject4P2WithBoth.subjectId) + } + + @Test + fun `performActions - Update - should add face and fingerprint samples`() = runTest { + // Given: Create initial subject + dataSource.performActions(listOf(SubjectAction.Creation(subject1P1WithFace)), project) + val initialSubject = + dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)).first() + assertThat(initialSubject.faceSamples).hasSize(1) + assertThat(initialSubject.fingerprintSamples).isEmpty() + + // Original Update action instantiation style maintained + val updateAction = SubjectAction.Update( + subjectId = subject1P1WithFace.subjectId, + faceSamplesToAdd = listOf(faceSample2), + fingerprintSamplesToAdd = listOf(fingerprintSample1), + referenceIdsToRemove = listOf(), + ) + + // When + dataSource.performActions(listOf(updateAction), project) + + // Then + val loaded = dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)) + assertThat(loaded).hasSize(1) + val updatedSubject = loaded[0] + assertThat(updatedSubject.faceSamples).hasSize(2) + assertThat(updatedSubject.faceSamples).containsExactly(faceSample1, faceSample2) + assertThat(updatedSubject.fingerprintSamples).hasSize(1) + assertThat(updatedSubject.fingerprintSamples).containsExactly(fingerprintSample1) + } + + @Test + fun `performActions - Update - should remove face sample when fingerprint sample exists`() = runTest { + // Given + val subjectToUpdate = subject3P1WithBoth + dataSource.performActions(listOf(SubjectAction.Creation(subjectToUpdate)), project) + val initial = + dataSource.load(SubjectQuery(subjectId = subjectToUpdate.subjectId)).first() + assertThat(initial.faceSamples).hasSize(1) + assertThat(initial.fingerprintSamples).hasSize(1) + + // Original Update action instantiation style maintained + val updateAction = SubjectAction.Update( + subjectId = subjectToUpdate.subjectId, + faceSamplesToAdd = listOf(), // Explicitly empty as in original + fingerprintSamplesToAdd = listOf(), // Explicitly empty as in original + referenceIdsToRemove = listOf(faceSample2.referenceId), + ) + + // When + dataSource.performActions(listOf(updateAction), project) + + // Then + val loaded = + dataSource.load(SubjectQuery(subjectId = subjectToUpdate.subjectId)).first() + assertThat(loaded.faceSamples).isEmpty() + assertThat(loaded.fingerprintSamples).hasSize(1) + assertThat(loaded.fingerprintSamples).containsExactly(fingerprintSample2) + } + + @Test + fun `performActions - Update - should remove fingerprint sample when face sample exists`() = runTest { + // Given + val subjectToUpdate = subject3P1WithBoth + dataSource.performActions(listOf(SubjectAction.Creation(subjectToUpdate)), project) + val initial = + dataSource.load(SubjectQuery(subjectId = subjectToUpdate.subjectId)).first() + assertThat(initial.faceSamples).hasSize(1) + assertThat(initial.fingerprintSamples).hasSize(1) + + // Original Update action instantiation style maintained + val updateAction = SubjectAction.Update( + subjectId = subjectToUpdate.subjectId, + faceSamplesToAdd = listOf(), + fingerprintSamplesToAdd = listOf(), + referenceIdsToRemove = listOf(fingerprintSample2.referenceId), + ) + + // When + dataSource.performActions(listOf(updateAction), project) + + // Then + val loaded = + dataSource.load(SubjectQuery(subjectId = subjectToUpdate.subjectId)).first() + assertThat(loaded.faceSamples).hasSize(1) + assertThat(loaded.faceSamples).containsExactly(faceSample2) + assertThat(loaded.fingerprintSamples).isEmpty() + } + + @Test(expected = IllegalArgumentException::class) // Reverted to JUnit exception check + fun `performActions - Update - should fail when removing last face sample`() = runTest { + // Given: Subject with only a face sample + dataSource.performActions(listOf(SubjectAction.Creation(subject1P1WithFace)), project) + val initial = + dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)).first() + assertThat(initial.faceSamples).hasSize(1) + assertThat(initial.fingerprintSamples).isEmpty() + + // Original Update action instantiation style maintained + val updateAction = SubjectAction.Update( + subjectId = subject1P1WithFace.subjectId, + faceSamplesToAdd = listOf(), + fingerprintSamplesToAdd = listOf(), + referenceIdsToRemove = listOf(faceSample1.referenceId), + ) + + // When + dataSource.performActions(listOf(updateAction), project) // This line will throw + + // Then: Handled by expected exception + } + + @Test(expected = IllegalArgumentException::class) // Reverted to JUnit exception check + fun `performActions - Update - should fail when removing last fingerprint sample`() = runTest { + // Given: Subject with only a fingerprint sample + dataSource.performActions(listOf(SubjectAction.Creation(subject2P1WithFinger)), project) + val initial = + dataSource.load(SubjectQuery(subjectId = subject2P1WithFinger.subjectId)).first() + assertThat(initial.faceSamples).isEmpty() + assertThat(initial.fingerprintSamples).hasSize(1) + + // Original Update action instantiation style maintained + val updateAction = SubjectAction.Update( + subjectId = subject2P1WithFinger.subjectId, + faceSamplesToAdd = listOf(), + fingerprintSamplesToAdd = listOf(), + referenceIdsToRemove = listOf(fingerprintSample1.referenceId), + ) + + // When + dataSource.performActions(listOf(updateAction), project) // This line will throw + + // Then: Handled by expected exception + } + + @Test + fun `performActions - Update - should not add duplicate samples based on ID`() = runTest { + // Given: Subject already has faceSample1 + dataSource.performActions(listOf(SubjectAction.Creation(subject1P1WithFace)), project) + + // Original Update action instantiation style maintained + val updateAction = SubjectAction.Update( + subjectId = subject1P1WithFace.subjectId, + faceSamplesToAdd = listOf(faceSample1, faceSample2), + fingerprintSamplesToAdd = listOf(fingerprintSample1), + referenceIdsToRemove = listOf(), + ) + + // When + dataSource.performActions(listOf(updateAction), project) + + // Then + val loaded = dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)) + assertThat(loaded).hasSize(1) + val finalSubject = loaded[0] + + assertThat(finalSubject.faceSamples).hasSize(2) + assertThat(finalSubject.faceSamples).containsExactly(faceSample1, faceSample2) + + assertThat(finalSubject.fingerprintSamples).hasSize(1) + assertThat(finalSubject.fingerprintSamples).containsExactly(fingerprintSample1) + } + + @Test + fun `performActions - Update - non-existent subjectId - should do nothing`() = runTest { + // Given + setupInitialData() + val initialCount = dataSource.count() + val nonExistentSubjectId = "subj-does-not-exist" + + val updateAction = SubjectAction.Update( + subjectId = nonExistentSubjectId, + faceSamplesToAdd = listOf(faceSample1), // Try to add samples + fingerprintSamplesToAdd = listOf(), + referenceIdsToRemove = listOf(), + ) + + // When + // No exception should be thrown here + dataSource.performActions(listOf(updateAction), project) + + // Then + val finalCount = dataSource.count() + assertThat(finalCount).isEqualTo(initialCount) // Count should not change + + // Verify the subject was not created + val loaded = dataSource.load(SubjectQuery(subjectId = nonExistentSubjectId)) + assertThat(loaded).isEmpty() + } + + @Test + fun `load - by no query - should return all subjects`() = runTest { + // Given + setupInitialData() + + // When + val loaded = dataSource.load(SubjectQuery()) + + // Then + assertThat(loaded).hasSize(initialData.size) + } + + @Test + fun `load - by subjectId - should return correct subject`() = runTest { + // Given + setupInitialData() + + // When + val loaded = dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)) + + // Then + assertThat(loaded).hasSize(1) + assertThat(loaded[0].subjectId).isEqualTo(subject1P1WithFace.subjectId) + assertThat(loaded[0].faceSamples).containsExactly(faceSample1) + assertThat(loaded[0].fingerprintSamples).isEmpty() + } + + @Test + fun `load - by hasUntokenizedFields -should return empty list`() = runTest { + // Given + setupInitialData() + + // When + val loaded = dataSource.load(SubjectQuery(hasUntokenizedFields = true)) + + // Then + assertThat(loaded).isEmpty() + } + + @Test + fun `load - by projectId - should return subjects for that project only`() = runTest { + // Given + setupInitialData() + + // When + val loadedP1 = dataSource.load(SubjectQuery(projectId = PROJECT_1_ID)) + val loadedP2 = dataSource.load(SubjectQuery(projectId = PROJECT_2_ID)) + + // Then + assertThat(loadedP1).hasSize(3) + val loadedP1Ids = loadedP1.map { it.subjectId } + assertThat(loadedP1Ids).containsExactly( + subject1P1WithFace.subjectId, + subject2P1WithFinger.subjectId, + subject3P1WithBoth.subjectId, + ) + assertThat(loadedP2).hasSize(3) + } + + @Test + fun `performActions - Deletion - should delete existing subject`() = runTest { + // Given + setupInitialData() + assertThat(dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId))).isNotEmpty() + + val deleteAction = SubjectAction.Deletion(subjectId = subject1P1WithFace.subjectId) + + // When + dataSource.performActions(listOf(deleteAction), project) + + // Then + val loaded = dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)) + assertThat(loaded).isEmpty() + assertThat(dataSource.count()).isEqualTo(initialData.size - 1) + } + + @Test + fun `combined actions - create, update, delete`() = runTest { + // --- Create --- + val createAction = SubjectAction.Creation(subject1P1WithFace) + dataSource.performActions(listOf(createAction), project) + var loadedSubject = + dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)).firstOrNull() + assertThat(loadedSubject).isNotNull() + assertThat(loadedSubject!!.faceSamples).hasSize(1) + assertThat(loadedSubject.fingerprintSamples).isEmpty() + + // --- Update (Original style maintained) --- + val updateAction = SubjectAction.Update( + subjectId = subject1P1WithFace.subjectId, + faceSamplesToAdd = listOf(), + fingerprintSamplesToAdd = listOf(fingerprintSample1), + referenceIdsToRemove = listOf(), + ) + dataSource.performActions(listOf(updateAction), project) + loadedSubject = + dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)).firstOrNull() + assertThat(loadedSubject).isNotNull() + assertThat(loadedSubject!!.faceSamples).hasSize(1) + assertThat(loadedSubject.fingerprintSamples).hasSize(1) + assertThat(loadedSubject.fingerprintSamples).containsExactly(fingerprintSample1) + + // --- Delete --- + val deleteAction = SubjectAction.Deletion(subjectId = subject1P1WithFace.subjectId) + dataSource.performActions(listOf(deleteAction), project) + val finalLoad = dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId)) + assertThat(finalLoad).isEmpty() + } + + @Test + fun `count - with no query - should return total number of subjects`() = runTest { + // Given + setupInitialData() + + // When + val count = dataSource.count() + + // Then + assertThat(count).isEqualTo(initialData.size) + } + + @Test + fun `count - by projectId - should return count for that project`() = runTest { + // Given + setupInitialData() + + // When + val countP1 = dataSource.count(SubjectQuery(projectId = PROJECT_1_ID)) + val countP2 = dataSource.count(SubjectQuery(projectId = PROJECT_2_ID)) + + // Then + assertThat(countP1).isEqualTo(3) + assertThat(countP2).isEqualTo(3) + } + + @Test + fun `count - by attendantId - should return count for that attendant`() = runTest { + // Given + setupInitialData() + + // When + val countAtt1 = dataSource.count(SubjectQuery(attendantId = ATTENDANT_1_ID)) + val countAtt2 = dataSource.count(SubjectQuery(attendantId = ATTENDANT_2_ID)) + + // Then + assertThat(countAtt1).isEqualTo(4) + assertThat(countAtt2).isEqualTo(2) + } + + @Test + fun `count - by moduleId - should return count for that module`() = runTest { + // Given + setupInitialData() + + // When + val countModule1 = dataSource.count(SubjectQuery(moduleId = MODULE_1_ID)) + val countModule2 = dataSource.count(SubjectQuery(moduleId = MODULE_2_ID)) + + // Then + assertThat(countModule1).isEqualTo(2) + assertThat(countModule2).isEqualTo(2) + } + + @Test + fun `count - by faceSampleFormat - should return count of subjects with matching format`() = runTest { + // Given + setupInitialData() + + // When + val countRoc1P1 = dataSource.count( + SubjectQuery( + projectId = PROJECT_1_ID, + faceSampleFormat = ROC_1_FORMAT, + ), + ) + val countRoc3P1 = dataSource.count( + SubjectQuery( + projectId = PROJECT_1_ID, + faceSampleFormat = ROC_3_FORMAT, + ), + ) + val countRoc1P2 = dataSource.count( + SubjectQuery( + projectId = PROJECT_2_ID, + faceSampleFormat = ROC_1_FORMAT, + ), + ) + val countAllRoc1 = dataSource.count(SubjectQuery(faceSampleFormat = ROC_1_FORMAT)) + + // Then + assertThat(countRoc1P1).isEqualTo(1) + assertThat(countRoc3P1).isEqualTo(1) + assertThat(countRoc1P2).isEqualTo(2) + assertThat(countAllRoc1).isEqualTo(3) + } + + @Test + fun `count - by fingerprintSampleFormat - should return count of subjects with matching format`() = runTest { + // Given + setupInitialData() + + // When + val countNecP1 = dataSource.count( + SubjectQuery( + projectId = PROJECT_1_ID, + fingerprintSampleFormat = NEC_FORMAT, + ), + ) + val countIsoP1 = dataSource.count( + SubjectQuery( + projectId = PROJECT_1_ID, + fingerprintSampleFormat = ISO_FORMAT, + ), + ) + val countNecP2 = dataSource.count( + SubjectQuery( + projectId = PROJECT_2_ID, + fingerprintSampleFormat = NEC_FORMAT, + ), + ) + val countAllNec = dataSource.count(SubjectQuery(fingerprintSampleFormat = NEC_FORMAT)) + + // Then + assertThat(countNecP1).isEqualTo(1) + assertThat(countIsoP1).isEqualTo(1) + assertThat(countNecP2).isEqualTo(2) + assertThat(countAllNec).isEqualTo(3) + } + + @Test + fun `count - with query matching nothing - should return zero`() = runTest { + // Given + setupInitialData() + + // When + val count = dataSource.count( + SubjectQuery( + projectId = "non-existent-project", + moduleId = "non-existent-module".asTokenizableEncrypted(), + attendantId = "non-existent-attendant".asTokenizableEncrypted(), + ), + ) + + // Then + assertThat(count).isEqualTo(0) + } + + @Test(expected = IllegalArgumentException::class) // Reverted to JUnit exception check + fun `loadFingerprintIdentities - should throw exception if format is missing in query`() = runTest { + // Given + setupInitialData() + val queryWithoutFormat = SubjectQuery(projectId = PROJECT_1_ID) + + // When + dataSource.loadFingerprintIdentities( + // This call will throw + query = queryWithoutFormat, + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ) + + // Then: Handled by expected exception + // Callback should not be called if exception occurs before iteration + verify(exactly = 0) { mockCallback() } + } + + @Test + fun `loadFingerprintIdentities - should load identities matching format for the specified project`() = runTest { + // Given + setupInitialData() + val project2Mock: Project = mockk { every { id } returns PROJECT_2_ID } + + // When - Query P1 for NEC + val loadedP1Nec = dataSource + .loadFingerprintIdentities( + query = SubjectQuery( + projectId = PROJECT_1_ID, + fingerprintSampleFormat = NEC_FORMAT, + ), + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + // When - Query P1 for ISO + val loadedP1Iso = dataSource + .loadFingerprintIdentities( + query = SubjectQuery( + projectId = PROJECT_1_ID, + fingerprintSampleFormat = ISO_FORMAT, + ), + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + // When - Query P2 for NEC + val loadedP2Nec = dataSource + .loadFingerprintIdentities( + query = SubjectQuery( + projectId = PROJECT_2_ID, + fingerprintSampleFormat = NEC_FORMAT, + ), + ranges = listOf(0..10), + project = project2Mock, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then - P1 NEC + assertThat(loadedP1Nec).hasSize(1) + assertThat(loadedP1Nec[0].subjectId).isEqualTo(subject2P1WithFinger.subjectId) + assertThat(loadedP1Nec[0].fingerprints).hasSize(1) + assertThat(loadedP1Nec[0].fingerprints[0].format).isEqualTo(NEC_FORMAT) + assertThat(loadedP1Nec[0].fingerprints).isEqualTo(subject2P1WithFinger.fingerprintSamples) + + // Then - P1 ISO + assertThat(loadedP1Iso).hasSize(1) + assertThat(loadedP1Iso[0].subjectId).isEqualTo(subject3P1WithBoth.subjectId) + assertThat(loadedP1Iso[0].fingerprints).hasSize(1) + assertThat(loadedP1Iso[0].fingerprints[0].format).isEqualTo(ISO_FORMAT) + assertThat(loadedP1Iso[0].fingerprints).isEqualTo(subject3P1WithBoth.fingerprintSamples) + + // Then - P2 NEC + assertThat(loadedP2Nec).hasSize(2) + assertThat(loadedP2Nec[0].subjectId).isEqualTo(subject4P2WithBoth.subjectId) + assertThat(loadedP2Nec[0].fingerprints).hasSize(1) + assertThat(loadedP2Nec[0].fingerprints[0].format).isEqualTo(NEC_FORMAT) + + verify(exactly = 4) { mockCallback() } + } + + @Test + fun `loadFingerprintIdentities - should respect the range parameter for specific format`() = runTest { + // Given: More data + val subject5P1WithNec = subject2P1WithFinger.copy( + subjectId = "subj-005", + fingerprintSamples = listOf( + fingerprintSample1.copy( + id = "fp-uuid-5", + referenceId = "ref-fp-5", + ), + ), + createdAt = Date(date.time + 1000), + ) + val subject6P1WithNec = subject2P1WithFinger.copy( + subjectId = "subj-006", + fingerprintSamples = listOf( + fingerprintSample1.copy( + id = "fp-uuid-6", + referenceId = "ref-fp-6", + ), + ), + createdAt = Date(date.time + 2000), + ) + setupInitialData() + dataSource.performActions( + listOf( + SubjectAction.Creation(subject5P1WithNec), + SubjectAction.Creation(subject6P1WithNec), + ), + project, + ) + val baseQuery = SubjectQuery( + projectId = PROJECT_1_ID, + fingerprintSampleFormat = NEC_FORMAT, + ) + // When + val loadedRanges = + dataSource + .loadFingerprintIdentities( + query = baseQuery, + ranges = listOf( + 0..1, + 2..3, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + + val loadedFirstTwo = + dataSource + .loadFingerprintIdentities( + query = baseQuery, + ranges = listOf( + 0..2, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedAll = + dataSource + .loadFingerprintIdentities( + query = baseQuery, + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then + assertThat(loadedRanges).hasSize(2) + assertThat(loadedRanges[0][0].subjectId).isEqualTo(subject2P1WithFinger.subjectId) + assertThat(loadedRanges[1]).hasSize(1) + assertThat(loadedRanges[1][0].subjectId).isEqualTo(subject5P1WithNec.subjectId) + assertThat(loadedFirstTwo).hasSize(2) + assertThat(loadedFirstTwo.map { it.subjectId }) + .containsExactly(subject2P1WithFinger.subjectId, subject5P1WithNec.subjectId) + .inOrder() + assertThat(loadedAll).hasSize(3) + assertThat(loadedAll.map { it.subjectId }) + .containsExactly( + subject2P1WithFinger.subjectId, + subject5P1WithNec.subjectId, + subject6P1WithNec.subjectId, + ).inOrder() + verify(exactly = 7) { mockCallback() } + } + + @Test + fun `loadFingerprintIdentities - with query format matching nothing - should return empty list`() = runTest { + // Given + setupInitialData() + + // When + val loadedIdentities = dataSource + .loadFingerprintIdentities( + query = SubjectQuery( + projectId = PROJECT_1_ID, + fingerprintSampleFormat = UNUSED_FORMAT, + ), + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then + assertThat(loadedIdentities).isEmpty() + verify(exactly = 0) { mockCallback() } + } + + @Test(expected = IllegalArgumentException::class) // Reverted to JUnit exception check + fun `loadFaceIdentities - should throw exception if format is missing in query`() = runTest { + // Given + setupInitialData() + val queryWithoutFormat = SubjectQuery(projectId = PROJECT_1_ID) + + // When + dataSource + .loadFaceIdentities( + // This call will throw + query = queryWithoutFormat, + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then: Handled by expected exception + verify(exactly = 0) { mockCallback() } + } + + @Test + fun `loadFaceIdentities - should load identities matching format for the specified project`() = runTest { + // Given + setupInitialData() + + // When + val loadedP1Roc1 = dataSource + .loadFaceIdentities( + query = SubjectQuery(projectId = PROJECT_1_ID, faceSampleFormat = ROC_1_FORMAT), + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedP1Roc3 = dataSource + .loadFaceIdentities( + query = SubjectQuery(projectId = PROJECT_1_ID, faceSampleFormat = ROC_3_FORMAT), + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedP2Roc1 = dataSource + .loadFaceIdentities( + query = SubjectQuery(projectId = PROJECT_2_ID, faceSampleFormat = ROC_1_FORMAT), + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then - P1 ROC_1 + assertThat(loadedP1Roc1).hasSize(1) + assertThat(loadedP1Roc1[0].subjectId).isEqualTo(subject1P1WithFace.subjectId) + assertThat(loadedP1Roc1[0].faces).hasSize(1) + assertThat(loadedP1Roc1[0].faces[0].format).isEqualTo(ROC_1_FORMAT) + assertThat(loadedP1Roc1[0].faces).isEqualTo(subject1P1WithFace.faceSamples) + + // Then - P1 ROC_3 + assertThat(loadedP1Roc3).hasSize(1) + assertThat(loadedP1Roc3[0].subjectId).isEqualTo(subject3P1WithBoth.subjectId) + assertThat(loadedP1Roc3[0].faces).hasSize(1) + assertThat(loadedP1Roc3[0].faces[0].format).isEqualTo(ROC_3_FORMAT) + assertThat(loadedP1Roc3[0].faces).isEqualTo(subject3P1WithBoth.faceSamples) + + // Then - P2 ROC_1 + assertThat(loadedP2Roc1).hasSize(2) + assertThat(loadedP2Roc1[0].subjectId).isEqualTo(subject4P2WithBoth.subjectId) + + verify(exactly = 4) { mockCallback() } + } + + @Test + fun `loadFaceIdentities - should respect the range parameter for specific format`() = runTest { + // Given: More data + val subject5P1WithRoc1 = subject1P1WithFace.copy( + subjectId = "subj-005", + faceSamples = listOf(faceSample1.copy(id = "face-uuid-5", referenceId = "ref-face-5")), + createdAt = Date(date.time + 1000), + ) + val subject6P1WithRoc1 = subject1P1WithFace.copy( + subjectId = "subj-006", + faceSamples = listOf(faceSample1.copy(id = "face-uuid-6", referenceId = "ref-face-6")), + createdAt = Date(date.time + 2000), + ) + setupInitialData() + dataSource.performActions( + listOf( + SubjectAction.Creation(subject5P1WithRoc1), + SubjectAction.Creation(subject6P1WithRoc1), + ), + project, + ) + val baseQuery = + SubjectQuery(projectId = PROJECT_1_ID, faceSampleFormat = ROC_1_FORMAT, sort = true) + // When + val loadedRanges = + dataSource + .loadFaceIdentities( + query = baseQuery, + ranges = listOf( + 0..1, + 2..3, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + + val loadedFirstTwo = + dataSource + .loadFaceIdentities( + query = baseQuery, + ranges = listOf( + 0..2, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedAll = dataSource + .loadFaceIdentities( + query = baseQuery, + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then + assertThat(loadedRanges).hasSize(2) // Two ranges loaded + assertThat(loadedRanges[0][0].subjectId).isEqualTo(subject1P1WithFace.subjectId) + assertThat(loadedRanges[1]).hasSize(1) + assertThat(loadedRanges[1][0].subjectId).isEqualTo(subject5P1WithRoc1.subjectId) + assertThat(loadedFirstTwo).hasSize(2) + assertThat(loadedFirstTwo.map { it.subjectId }) + .containsExactly(subject1P1WithFace.subjectId, subject5P1WithRoc1.subjectId) + .inOrder() + assertThat(loadedAll).hasSize(3) + assertThat(loadedAll.map { it.subjectId }) + .containsExactly( + subject1P1WithFace.subjectId, + subject5P1WithRoc1.subjectId, + subject6P1WithRoc1.subjectId, + ).inOrder() + verify(exactly = 7) { mockCallback() } + } + + @Test + fun `loadFaceIdentities - with query format matching nothing - should return empty list`() = runTest { + // Given + setupInitialData() + + // When + val loadedIdentities = dataSource + .loadFaceIdentities( + query = SubjectQuery( + projectId = PROJECT_1_ID, + faceSampleFormat = UNUSED_FORMAT, + ), + ranges = listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then + assertThat(loadedIdentities).isEmpty() + verify(exactly = 0) { mockCallback() } + } + + @Test + fun `loadFaceIdentities - by attendantId and moduleId - should return correct identities`() = runTest { + // Given + setupInitialData() + + // Query for Project 1, Attendant 1, Module 1, Format ROC_1 + val queryP1A1M1Roc1 = SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_1_ID, + faceSampleFormat = ROC_1_FORMAT, + ) + + // Query for Project 1, Attendant 1, Module 2, Format ROC_3 + val queryP1A1M2Roc3 = SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_2_ID, + faceSampleFormat = ROC_3_FORMAT, + ) + + // Query for Project 1, Attendant 1, Module 1, Format ROC_3 (should be empty) + val queryP1A1M1Roc3Empty = SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_1_ID, + faceSampleFormat = ROC_3_FORMAT, + ) + + // When + val loadedP1A1M1Roc1 = dataSource + .loadFaceIdentities( + queryP1A1M1Roc1, + listOf(0..10), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedP1A1M2Roc3 = dataSource + .loadFaceIdentities( + queryP1A1M2Roc3, + listOf( + 0..10, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedP1A1M1Roc3Empty = + dataSource + .loadFaceIdentities( + queryP1A1M1Roc3Empty, + listOf( + 0..10, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then + assertThat(loadedP1A1M1Roc1).hasSize(1) + assertThat(loadedP1A1M1Roc1[0].subjectId).isEqualTo(subject1P1WithFace.subjectId) + + assertThat(loadedP1A1M2Roc3).hasSize(1) + assertThat(loadedP1A1M2Roc3[0].subjectId).isEqualTo(subject3P1WithBoth.subjectId) + + assertThat(loadedP1A1M1Roc3Empty).isEmpty() + + verify(exactly = 2) { mockCallback() } // Called for the 3 successful loads + } + + @Test + fun `loadFingerprintIdentities - by attendantId and moduleId - should return correct identities`() = runTest { + // Given + setupInitialData() + + // Query for Project 1, Attendant 1, Module 1, Format NEC + val queryP1A1M1Nec = SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_1_ID, + fingerprintSampleFormat = NEC_FORMAT, + ) + + // Query for Project 1, Attendant 1, Module 3, Format NEC + val queryP1A1M3Nec = SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_3_ID, + fingerprintSampleFormat = NEC_FORMAT, + ) + + // Query for Project 1, Attendant 1, Module 2, Format ISO + val queryP1A1M2Iso = SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_2_ID, + fingerprintSampleFormat = ISO_FORMAT, + ) + + // Query for Project 1, Attendant 2, Module 1, Format NEC (should be empty) + val queryP1A2M1NecEmpty = SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_2_ID, + moduleId = MODULE_1_ID, + fingerprintSampleFormat = NEC_FORMAT, + ) + + // When + val loadedP1A1M1Nec = dataSource + .loadFingerprintIdentities( + queryP1A1M1Nec, + listOf( + 0..10, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedP1A1M3Nec = dataSource + .loadFingerprintIdentities( + queryP1A1M3Nec, + listOf( + 0..10, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedP1A1M2Iso = dataSource + .loadFingerprintIdentities( + queryP1A1M2Iso, + listOf( + 0..10, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + val loadedP1A2M1NecEmpty = dataSource + .loadFingerprintIdentities( + queryP1A2M1NecEmpty, + listOf( + 0..10, + ), + project = project, + dataSource = Simprints, + scope = this, + onCandidateLoaded = mockCallback, + ).toList() + .first() + + // Then + assertThat(loadedP1A1M1Nec).hasSize(1) + assertThat(loadedP1A1M1Nec[0].subjectId).isEqualTo(subject2P1WithFinger.subjectId) + + assertThat(loadedP1A1M3Nec).hasSize(0) + + assertThat(loadedP1A1M2Iso).hasSize(1) + assertThat(loadedP1A1M2Iso[0].subjectId).isEqualTo(subject3P1WithBoth.subjectId) + + assertThat(loadedP1A2M1NecEmpty).isEmpty() + + verify(exactly = 2) { mockCallback() } + } + + @Test + fun `load - by attendantId and moduleId - should return matching subjects`() = runTest { + // Given + setupInitialData() + + // When + val loadedP1A1M1 = dataSource.load( + SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_1_ID, + ), + ) + val loadedP1A1M2 = dataSource.load( + SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + moduleId = MODULE_2_ID, + ), + ) + val loadedP1A2M1 = dataSource.load( + SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_2_ID, + moduleId = MODULE_1_ID, + ), + ) + val loadedP2A2M2 = dataSource.load( + SubjectQuery( + projectId = PROJECT_2_ID, + attendantId = ATTENDANT_2_ID, + moduleId = MODULE_2_ID, + ), + ) + + // Then + assertThat(loadedP1A1M1).hasSize(2) + assertThat(loadedP1A1M1.map { it.subjectId }).containsExactly( + subject1P1WithFace.subjectId, + subject2P1WithFinger.subjectId, + ) + + assertThat(loadedP1A1M2).hasSize(1) + assertThat(loadedP1A1M2[0].subjectId).isEqualTo(subject3P1WithBoth.subjectId) + + assertThat(loadedP1A2M1).hasSize(0) + + assertThat(loadedP2A2M2).hasSize(1) + assertThat(loadedP2A2M2[0].subjectId).isEqualTo(subject4P2WithBoth.subjectId) + } + + @Test + fun `load - by subjectIds - should return only specified subjects`() = runTest { + // Given + setupInitialData() + val targetIds = listOf( + subject1P1WithFace.subjectId, + subject4P2WithBoth.subjectId, + subject6P2WithFinger.subjectId, + ) + + // When + val loaded = dataSource.load( + SubjectQuery( + subjectIds = targetIds, + sort = true, + ), + ) // Sort for predictable order + + // Then + assertThat(loaded).hasSize(3) + assertThat(loaded.map { it.subjectId }) + .containsExactlyElementsIn(targetIds.sorted()) + .inOrder() // Compare sorted lists + // Check details of one subject + val loadedSubject1 = loaded.find { it.subjectId == subject1P1WithFace.subjectId } + assertThat(loadedSubject1).isNotNull() + assertThat(loadedSubject1?.attendantId).isEqualTo(ATTENDANT_1_ID) + assertThat(loadedSubject1?.moduleId).isEqualTo(MODULE_1_ID) + assertThat(loadedSubject1?.faceSamples).isEqualTo(subject1P1WithFace.faceSamples) + } + + @Test + fun `load - by afterSubjectId - should return subjects after the specified ID`() = runTest { + // Given + setupInitialData() + val allSubjectIdsSorted = listOf( + subject1P1WithFace.subjectId, // subj-001 + subject2P1WithFinger.subjectId, // subj-002 + subject3P1WithBoth.subjectId, // subj-003 + subject4P2WithBoth.subjectId, // subj-004 + subject5P2WithFace.subjectId, // subj-005 + subject6P2WithFinger.subjectId, // subj-006 + ).sorted() + + val afterId = allSubjectIdsSorted[2] // Should be subj-003 + + // When + // Query for all subjects after subj-003, sorted by ID + val loaded = dataSource.load(SubjectQuery(afterSubjectId = afterId, sort = true)) + + // Then + assertThat(loaded).hasSize(3) // subj-004, subj-005, subj-006 remain + assertThat(loaded.map { it.subjectId }) + .containsExactly( + allSubjectIdsSorted[3], // subj-004 + allSubjectIdsSorted[4], // subj-005 + allSubjectIdsSorted[5], // subj-006 + ).inOrder() + } + + @Test + fun `load - combined query - attendantId, moduleId, subjectIds - should respect all filters`() = runTest { + // Given + setupInitialData() + // Targets: subj-001, subj-002 (P1, A1, M1), subj-003 (P1, A1, M2) + val targetIds = listOf( + subject2P1WithFinger.subjectId, // subj-002 + subject3P1WithBoth.subjectId, // subj-003 + "subj-nonexistent", // Include a non-existent ID + ) + + // Query: Project 1, Attendant 1, from the targetIds list, sorted + val query = SubjectQuery( + projectId = PROJECT_1_ID, + attendantId = ATTENDANT_1_ID, + subjectIds = targetIds, + sort = true, + ) + + // When + val loaded = dataSource.load(query) + + // Then + // Expected: subj-002, subj-003 meet all criteria (P1, A1, > subj-001, in targetIds) + assertThat(loaded).hasSize(2) + assertThat(loaded.map { it.subjectId }) + .containsExactly( + subject2P1WithFinger.subjectId, // subj-002 + subject3P1WithBoth.subjectId, // subj-003 + ).inOrder() + } + + // 3. Test delete by module id + + @Test + fun `delete - by moduleId - should delete only subjects with matching moduleId`() = runTest { + // Given + setupInitialData() + val initialCount = dataSource.count() + val countModule1Before = dataSource.count(SubjectQuery(moduleId = MODULE_1_ID)) + val countModule2Before = dataSource.count(SubjectQuery(moduleId = MODULE_2_ID)) + val countModule3Before = dataSource.count(SubjectQuery(moduleId = MODULE_3_ID)) + + assertThat(countModule1Before).isEqualTo(2) + assertThat(countModule2Before).isEqualTo(2) + assertThat(countModule3Before).isEqualTo(2) + + val queryToDeleteModule1 = SubjectQuery(moduleId = MODULE_1_ID) + + // When + dataSource.delete(listOf(queryToDeleteModule1)) + + // Then + val finalCount = dataSource.count() + val countModule1After = dataSource.count(SubjectQuery(moduleId = MODULE_1_ID)) + val countModule2After = dataSource.count(SubjectQuery(moduleId = MODULE_2_ID)) + val countModule3After = dataSource.count(SubjectQuery(moduleId = MODULE_3_ID)) + + assertThat(countModule1After).isEqualTo(0) // All M1 deleted + assertThat(countModule2After).isEqualTo(2) // M2 untouched + assertThat(countModule3After).isEqualTo(2) // M3 untouched + assertThat(finalCount).isEqualTo(initialCount - countModule1Before) // Total count reduced correctly + + // Verify specific subjects are gone/remain + assertThat(dataSource.load(SubjectQuery(subjectId = subject1P1WithFace.subjectId))).isEmpty() + assertThat(dataSource.load(SubjectQuery(subjectId = subject2P1WithFinger.subjectId))).isEmpty() + assertThat(dataSource.load(SubjectQuery(subjectId = subject5P2WithFace.subjectId))).isNotEmpty() + assertThat(dataSource.load(SubjectQuery(subjectId = subject3P1WithBoth.subjectId))).isNotEmpty() + assertThat(dataSource.load(SubjectQuery(subjectId = subject4P2WithBoth.subjectId))).isNotEmpty() + assertThat(dataSource.load(SubjectQuery(subjectId = subject6P2WithFinger.subjectId))).isNotEmpty() + } + + @Test(expected = IllegalArgumentException::class) + fun `delete - by moduleId with format specified - should throw exception`() = runTest { + // Given + setupInitialData() + val queryToDeleteModule1WithFormat = SubjectQuery( + moduleId = MODULE_1_ID, + faceSampleFormat = ROC_1_FORMAT, // Adding format which is not allowed for delete + ) + + // When + dataSource.delete(listOf(queryToDeleteModule1WithFormat)) // This should throw + + // Then: Exception expected + } +} diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordQueryBuilderTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordQueryBuilderTest.kt new file mode 100644 index 0000000000..d68c21095c --- /dev/null +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordQueryBuilderTest.kt @@ -0,0 +1,455 @@ +package com.simprints.infra.enrolment.records.repository.local + +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteProgram +import com.google.common.truth.Truth.* +import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate.Companion.FORMAT_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate.Companion.TEMPLATE_TABLE_NAME +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.ATTENDANT_ID_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.MODULE_ID_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.PROJECT_ID_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.SUBJECT_ID_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.SUBJECT_TABLE_NAME +import com.simprints.testtools.common.syntax.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class RoomEnrolmentRecordQueryBuilderTest { + private lateinit var queryBuilder: RoomEnrolmentRecordQueryBuilder + + @Before + fun setUp() { + queryBuilder = RoomEnrolmentRecordQueryBuilder() + } + + @Test + fun `buildSubjectQuery with empty query returns select all`() { + val subjectQuery = SubjectQuery() + val expectedSql = "SELECT * FROM $SUBJECT_TABLE_NAME S" + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql) + assertThat(resultQuery.argCount).isEqualTo(0) + } + + @Test + fun `buildSubjectQuery with subjectId`() { + val subjectId = "test-subject-id" + val subjectQuery = SubjectQuery(subjectId = subjectId) + val expectedSql = "SELECT * FROM $SUBJECT_TABLE_NAME S\n" + + "WHERE S.$SUBJECT_ID_COLUMN = ?" + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(subjectId)) + } + + @Test + fun `buildSubjectQuery with subjectIds`() { + val subjectIds = listOf("id1", "id2", "id3") + val subjectQuery = SubjectQuery(subjectIds = subjectIds) + val expectedSql = "SELECT * FROM $SUBJECT_TABLE_NAME S\n" + + "WHERE S.$SUBJECT_ID_COLUMN IN (?,?,?)" + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(getArgs(resultQuery)).isEqualTo(subjectIds.toTypedArray()) + } + + @Test + fun `buildSubjectQuery with afterSubjectId`() { + val afterSubjectId = "last-subject-id" + val subjectQuery = SubjectQuery(afterSubjectId = afterSubjectId) + val expectedSql = "SELECT * FROM $SUBJECT_TABLE_NAME S\n" + + "WHERE S.$SUBJECT_ID_COLUMN > ?" + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(afterSubjectId)) + } + + @Test + fun `buildSubjectQuery with projectId`() { + val projectId = "test-project-id" + val subjectQuery = SubjectQuery(projectId = projectId) + val expectedSql = "SELECT * FROM $SUBJECT_TABLE_NAME S\n" + + "WHERE S.$PROJECT_ID_COLUMN = ?" + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(projectId)) + } + + @Test + fun `buildSubjectQuery with attendantId`() { + val attendantId = "test-attendant-id".asTokenizableEncrypted() + val subjectQuery = SubjectQuery(attendantId = attendantId) + val expectedSql = "SELECT * FROM $SUBJECT_TABLE_NAME S\n" + + "WHERE S.$ATTENDANT_ID_COLUMN = ?" + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(attendantId.value)) + } + + @Test + fun `buildSubjectQuery with moduleId`() { + val moduleId = "test-module-id".asTokenizableEncrypted() + val subjectQuery = SubjectQuery(moduleId = moduleId) + val expectedSql = "SELECT * FROM $SUBJECT_TABLE_NAME S\n" + + "WHERE S.$MODULE_ID_COLUMN = ?" + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(moduleId.value)) + } + + @Test + fun `buildSubjectQuery with sort true`() { + val subjectQuery = SubjectQuery(sort = true) + val expectedSql = + """ + SELECT * FROM $SUBJECT_TABLE_NAME S + + ORDER BY S.$SUBJECT_ID_COLUMN ASC + """.trimIndent() + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(resultQuery.argCount).isEqualTo(0) + } + + @Test + fun `buildSubjectQuery with multiple parameters and sort`() { + val projectId = "proj1" + val attendantId = "att1".asTokenizableEncrypted() + val subjectQuery = SubjectQuery(projectId = projectId, attendantId = attendantId, sort = true) + val expectedSql = "SELECT * FROM $SUBJECT_TABLE_NAME S\n" + + "WHERE S.$PROJECT_ID_COLUMN = ? AND S.$ATTENDANT_ID_COLUMN = ?\n" + + "ORDER BY S.$SUBJECT_ID_COLUMN ASC" + + val resultQuery = queryBuilder.buildSubjectQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(projectId, attendantId.value)) + } + + @Test + fun `buildSubjectQuery throws error if fingerprintSampleFormat is set`() { + val subjectQuery = SubjectQuery(fingerprintSampleFormat = "ISO_19794_2_2005") + val exception = assertThrows { + queryBuilder.buildSubjectQuery(subjectQuery) + } + assertThat(exception.message).isEqualTo("Cannot set format for subject query, use buildBiometricTemplatesQuery instead") + } + + @Test + fun `buildSubjectQuery throws error if faceSampleFormat is set`() { + val subjectQuery = SubjectQuery(faceSampleFormat = "RAW") + val exception = assertThrows { + queryBuilder.buildSubjectQuery(subjectQuery) + } + assertThat(exception.message).isEqualTo("Cannot set format for subject query, use buildBiometricTemplatesQuery instead") + } + + @Test + fun `buildCountQuery with empty query`() { + val subjectQuery = SubjectQuery() + val expectedSql = "SELECT COUNT(DISTINCT S.$SUBJECT_ID_COLUMN) FROM $SUBJECT_TABLE_NAME S" + + val resultQuery = queryBuilder.buildCountQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(resultQuery.argCount).isEqualTo(0) + } + + @Test + fun `buildCountQuery with subjectId`() { + val subjectId = "s1" + val subjectQuery = SubjectQuery(subjectId = subjectId) + val expectedSql = + "SELECT COUNT(DISTINCT S.$SUBJECT_ID_COLUMN) FROM $SUBJECT_TABLE_NAME S WHERE S.$SUBJECT_ID_COLUMN = ?" + + val resultQuery = queryBuilder.buildCountQuery(subjectQuery) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(subjectId)) + } + + @Test + fun `buildCountQuery with projectId`() { + val projectId = "p1" + val subjectQuery = SubjectQuery(projectId = projectId) + val expectedSql = + "SELECT COUNT(DISTINCT S.$SUBJECT_ID_COLUMN) FROM $SUBJECT_TABLE_NAME S WHERE S.$PROJECT_ID_COLUMN = ?" + + val resultQuery = queryBuilder.buildCountQuery(subjectQuery) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(projectId)) + } + + @Test + fun `buildCountQuery with fingerprintSampleFormat`() { + val format = "ISO_FP" + val subjectQuery = SubjectQuery(fingerprintSampleFormat = format) + val expectedSql = + "SELECT COUNT(DISTINCT S.$SUBJECT_ID_COLUMN) FROM $SUBJECT_TABLE_NAME S INNER JOIN $TEMPLATE_TABLE_NAME T" + + " using(subjectId) WHERE T.$FORMAT_COLUMN = ?" + + val resultQuery = queryBuilder.buildCountQuery(subjectQuery) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(format)) + } + + @Test + fun `buildCountQuery with faceSampleFormat`() { + val format = "RAW_FACE" + val subjectQuery = SubjectQuery(faceSampleFormat = format) + val expectedSql = + "SELECT COUNT(DISTINCT S.$SUBJECT_ID_COLUMN) FROM $SUBJECT_TABLE_NAME S INNER JOIN $TEMPLATE_TABLE_NAME T" + + " using(subjectId) WHERE T.$FORMAT_COLUMN = ?" + + val resultQuery = queryBuilder.buildCountQuery(subjectQuery) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(format)) + } + + @Test + fun `buildCountQuery with projectId and fingerprintSampleFormat`() { + val projectId = "p1" + val format = "ISO_FP" + val subjectQuery = SubjectQuery(projectId = projectId, fingerprintSampleFormat = format) + val expectedSql = + "SELECT COUNT(DISTINCT S.$SUBJECT_ID_COLUMN) FROM $SUBJECT_TABLE_NAME S INNER JOIN $TEMPLATE_TABLE_NAME T" + + " using(subjectId) WHERE S.$PROJECT_ID_COLUMN = ? AND T.$FORMAT_COLUMN = ?" + + val resultQuery = queryBuilder.buildCountQuery(subjectQuery) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(projectId, format)) + } + + @Test + fun `buildCountQuery throws if both fingerprint and face formats set`() { + val subjectQuery = SubjectQuery(fingerprintSampleFormat = "FP_FORMAT", faceSampleFormat = "FACE_FORMAT") + val exception = assertThrows { + queryBuilder.buildCountQuery(subjectQuery) + } + assertThat(exception.message).isEqualTo("Cannot set both fingerprintSampleFormat and faceSampleFormat") + } + + @Test + fun `buildBiometricTemplatesQuery throws error if format is not set`() { + val subjectQuery = SubjectQuery() + val pageSize = 10 + val exception = assertThrows { + queryBuilder.buildBiometricTemplatesQuery(subjectQuery, pageSize) + } + assertThat( + exception.message, + ).isEqualTo("Must set format for biometric templates query, use buildSubjectQuery or buildCountQuery instead") + } + + @Test + fun `buildBiometricTemplatesQuery with fingerprintSampleFormat`() { + val format = "ISO_FP_TEMPLATE" + val pageSize = 10 + val subjectQuery = SubjectQuery(fingerprintSampleFormat = format) + val expectedSql = + """ + SELECT A.* + FROM $TEMPLATE_TABLE_NAME A + INNER JOIN ( + SELECT distinct S.subjectId + FROM $SUBJECT_TABLE_NAME S INNER JOIN $TEMPLATE_TABLE_NAME T + USING(subjectId) + WHERE T.$FORMAT_COLUMN = ? + ORDER BY S.$SUBJECT_ID_COLUMN ASC + LIMIT $pageSize + ) B USING(subjectId) where A.format ='$format' + """.trimIndent() + + val resultQuery = queryBuilder.buildBiometricTemplatesQuery(subjectQuery, pageSize) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(format)) + } + + @Test + fun `buildBiometricTemplatesQuery with faceSampleFormat and projectId`() { + val format = "RAW_FACE_TEMPLATE" + val projectId = "projX" + val pageSize = 5 + val subjectQuery = SubjectQuery(faceSampleFormat = format, projectId = projectId) + val expectedSql = + """ + SELECT A.* + FROM $TEMPLATE_TABLE_NAME A + INNER JOIN ( + SELECT distinct S.subjectId + FROM $SUBJECT_TABLE_NAME S INNER JOIN $TEMPLATE_TABLE_NAME T + USING(subjectId) + WHERE S.$PROJECT_ID_COLUMN = ? AND T.$FORMAT_COLUMN = ? + ORDER BY S.$SUBJECT_ID_COLUMN ASC + LIMIT $pageSize + ) B USING(subjectId) where A.format ='$format' + """.trimIndent() + + val resultQuery = queryBuilder.buildBiometricTemplatesQuery(subjectQuery, pageSize) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(projectId, format)) + } + + @Test + fun `buildBiometricTemplatesQuery uses sort true internally`() { + val format = "ANY_FORMAT" + val pageSize = 15 + val subjectQuery = SubjectQuery(fingerprintSampleFormat = format, sort = false) + val expectedSql = + """ + SELECT A.* + FROM $TEMPLATE_TABLE_NAME A + INNER JOIN ( + SELECT distinct S.subjectId + FROM $SUBJECT_TABLE_NAME S INNER JOIN $TEMPLATE_TABLE_NAME T + USING(subjectId) + WHERE T.$FORMAT_COLUMN = ? + ORDER BY S.$SUBJECT_ID_COLUMN ASC + LIMIT $pageSize + ) B USING(subjectId) where A.format ='$format' + """.trimIndent() + + val resultQuery = queryBuilder.buildBiometricTemplatesQuery(subjectQuery, pageSize) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(format)) + } + + @Test + fun `buildBiometricTemplatesQuery throws if both fingerprint and face formats set`() { + val subjectQuery = SubjectQuery(fingerprintSampleFormat = "FP_FORMAT", faceSampleFormat = "FACE_FORMAT") + val pageSize = 10 + val exception = assertThrows { + queryBuilder.buildBiometricTemplatesQuery(subjectQuery, pageSize) + } + assertThat(exception.message).isEqualTo("Cannot set both fingerprintSampleFormat and faceSampleFormat") + } + + @Test + fun `buildDeleteQuery throws error if fingerprintSampleFormat is set`() { + val subjectQuery = SubjectQuery(fingerprintSampleFormat = "ISO_19794_2_2005") + val exception = assertThrows { + queryBuilder.buildDeleteQuery(subjectQuery) + } + assertThat(exception.message).isEqualTo("faceSampleFormat and fingerprintSampleFormat are not supported for deletion") + } + + @Test + fun `buildDeleteQuery throws error if faceSampleFormat is set`() { + val subjectQuery = SubjectQuery(faceSampleFormat = "RAW") + val exception = assertThrows { + queryBuilder.buildDeleteQuery(subjectQuery) + } + assertThat(exception.message).isEqualTo("faceSampleFormat and fingerprintSampleFormat are not supported for deletion") + } + + @Test + fun `buildDeleteQuery with empty query`() { + val subjectQuery = SubjectQuery() + val expectedSql = "DELETE FROM DbSubject" + + val resultQuery = queryBuilder.buildDeleteQuery(subjectQuery) + + assertThat(resultQuery.sql.trim()).isEqualTo(expectedSql.trim()) + assertThat(resultQuery.argCount).isEqualTo(0) + } + + @Test + fun `buildDeleteQuery with subjectId`() { + val subjectId = "id-to-delete" + val subjectQuery = SubjectQuery(subjectId = subjectId) + val expectedSql = "DELETE FROM DbSubject WHERE $SUBJECT_ID_COLUMN = ?" + + val resultQuery = queryBuilder.buildDeleteQuery(subjectQuery) + + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(subjectId)) + } + + @Test + fun `buildDeleteQuery with projectId and moduleId`() { + val projectId = "proj-del" + val moduleId = "mod-del".asTokenizableEncrypted() + val subjectQuery = SubjectQuery(projectId = projectId, moduleId = moduleId) + val expectedSql = "DELETE FROM DbSubject WHERE $PROJECT_ID_COLUMN = ? AND $MODULE_ID_COLUMN = ?" + val resultQuery = queryBuilder.buildDeleteQuery(subjectQuery) + assertThat(resultQuery.sql).isEqualTo(expectedSql) + assertThat(getArgs(resultQuery)).isEqualTo(arrayOf(projectId, moduleId.value)) + } + + private fun getArgs(query: SimpleSQLiteQuery): Array { + val argsMap = mutableMapOf() + val program = object : SupportSQLiteProgram { + override fun bindNull(index: Int) { + argsMap[index] = null + } + + override fun bindLong( + index: Int, + value: Long, + ) { + argsMap[index] = value + } + + override fun bindDouble( + index: Int, + value: Double, + ) { + argsMap[index] = value + } + + override fun bindString( + index: Int, + value: String, + ) { + argsMap[index] = value + } + + override fun bindBlob( + index: Int, + value: ByteArray, + ) { + argsMap[index] = value + } + + override fun clearBindings() { + argsMap.clear() + } + + override fun close() {} + } + + query.bindTo(program) + + val maxIndex = argsMap.keys.maxOrNull() ?: 0 + return Array(maxIndex) { i -> argsMap[i + 1] } + } +} diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/models/DbSubjectTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/models/DbSubjectTest.kt index 3986a3cb69..74bd034aee 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/models/DbSubjectTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/models/DbSubjectTest.kt @@ -46,7 +46,7 @@ class DbSubjectTest { faceSamples = listOf(faceSample), ) - val dbSubject = domainSubject.fromDomainToDb() + val dbSubject = domainSubject.toRealmDb() with(dbSubject) { assertThat(subjectId).isEqualTo(RealmUUID.from(GUID)) @@ -89,7 +89,7 @@ class DbSubjectTest { isAttendantIdTokenized = true } - val domainSubject = dbSubject.fromDbToDomain() + val domainSubject = dbSubject.toDomain() with(domainSubject) { assertThat(subjectId).isEqualTo(GUID) diff --git a/infra/enrolment-records/room-store/build.gradle.kts b/infra/enrolment-records/room-store/build.gradle.kts new file mode 100644 index 0000000000..fdac1ec1fd --- /dev/null +++ b/infra/enrolment-records/room-store/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("simprints.infra") + id("simprints.library.room") +} + +android { + namespace = "com.simprints.infra.enrolment.records.room.store" +} + +dependencies { + implementation(project(":infra:auth-store")) +} diff --git a/infra/enrolment-records/room-store/schemas/com.simprints.infra.enrolment.records.room.store.SubjectsDatabase/1.json b/infra/enrolment-records/room-store/schemas/com.simprints.infra.enrolment.records.room.store.SubjectsDatabase/1.json new file mode 100644 index 0000000000..bbe29315e1 --- /dev/null +++ b/infra/enrolment-records/room-store/schemas/com.simprints.infra.enrolment.records.room.store.SubjectsDatabase/1.json @@ -0,0 +1,180 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "94bee827928a2618c6873579bc6bc63a", + "entities": [ + { + "tableName": "DbSubject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subjectId` TEXT NOT NULL, `projectId` TEXT NOT NULL, `attendantId` TEXT NOT NULL, `moduleId` TEXT NOT NULL, `createdAt` INTEGER, `updatedAt` INTEGER, PRIMARY KEY(`subjectId`))", + "fields": [ + { + "fieldPath": "subjectId", + "columnName": "subjectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attendantId", + "columnName": "attendantId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "moduleId", + "columnName": "moduleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subjectId" + ] + }, + "indices": [ + { + "name": "index_DbSubject_projectId_subjectId", + "unique": false, + "columnNames": [ + "projectId", + "subjectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbSubject_projectId_subjectId` ON `${TABLE_NAME}` (`projectId`, `subjectId`)" + }, + { + "name": "index_DbSubject_projectId_moduleId_subjectId", + "unique": false, + "columnNames": [ + "projectId", + "moduleId", + "subjectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbSubject_projectId_moduleId_subjectId` ON `${TABLE_NAME}` (`projectId`, `moduleId`, `subjectId`)" + }, + { + "name": "index_DbSubject_projectId_attendantId_subjectId", + "unique": false, + "columnNames": [ + "projectId", + "attendantId", + "subjectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbSubject_projectId_attendantId_subjectId` ON `${TABLE_NAME}` (`projectId`, `attendantId`, `subjectId`)" + } + ] + }, + { + "tableName": "DbBiometricTemplate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `subjectId` TEXT NOT NULL, `identifier` INTEGER, `templateData` BLOB NOT NULL, `format` TEXT NOT NULL, `referenceId` TEXT NOT NULL, `modality` INTEGER NOT NULL, PRIMARY KEY(`uuid`), FOREIGN KEY(`subjectId`) REFERENCES `DbSubject`(`subjectId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subjectId", + "columnName": "subjectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identifier", + "columnName": "identifier", + "affinity": "INTEGER" + }, + { + "fieldPath": "templateData", + "columnName": "templateData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceId", + "columnName": "referenceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modality", + "columnName": "modality", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_DbBiometricTemplate_format_subjectId", + "unique": false, + "columnNames": [ + "format", + "subjectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbBiometricTemplate_format_subjectId` ON `${TABLE_NAME}` (`format`, `subjectId`)" + }, + { + "name": "index_DbBiometricTemplate_subjectId", + "unique": false, + "columnNames": [ + "subjectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbBiometricTemplate_subjectId` ON `${TABLE_NAME}` (`subjectId`)" + } + ], + "foreignKeys": [ + { + "table": "DbSubject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subjectId" + ], + "referencedColumns": [ + "subjectId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '94bee827928a2618c6873579bc6bc63a')" + ] + } +} \ No newline at end of file diff --git a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectDao.kt b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectDao.kt new file mode 100644 index 0000000000..d7836eb8cf --- /dev/null +++ b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectDao.kt @@ -0,0 +1,42 @@ +package com.simprints.infra.enrolment.records.room.store + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.MapColumn +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.sqlite.db.SupportSQLiteQuery +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate +import com.simprints.infra.enrolment.records.room.store.models.DbSubject +import com.simprints.infra.enrolment.records.room.store.models.SubjectBiometrics + +@Dao +interface SubjectDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSubject(subject: DbSubject) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertBiometricSamples(samples: List) + + @Query("DELETE FROM DbSubject WHERE subjectId = :subjectId") + suspend fun deleteSubject(subjectId: String) + + @Query("DELETE FROM DbBiometricTemplate WHERE uuid = :uuid") + suspend fun deleteBiometricSample(uuid: String) + + @RawQuery + suspend fun deleteSubjects(query: SupportSQLiteQuery): Int + + @Query("SELECT * FROM DbSubject WHERE subjectId = :subjectId") + suspend fun getSubject(subjectId: String): SubjectBiometrics? + + @RawQuery + suspend fun loadSubjects(query: SupportSQLiteQuery): List + + @RawQuery + suspend fun countSubjects(query: SupportSQLiteQuery): Int + + @RawQuery + suspend fun loadSamples(query: SupportSQLiteQuery): Map<@MapColumn(DbSubject.SUBJECT_ID_COLUMN) String, List> +} diff --git a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectsDatabase.kt b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectsDatabase.kt new file mode 100644 index 0000000000..16f004f62d --- /dev/null +++ b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectsDatabase.kt @@ -0,0 +1,39 @@ +package com.simprints.infra.enrolment.records.room.store + +import android.content.Context +import androidx.annotation.Keep +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate +import com.simprints.infra.enrolment.records.room.store.models.DbSubject +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import javax.inject.Singleton + +@Singleton +@Database( + entities = [ + DbSubject::class, + DbBiometricTemplate::class, + ], + version = 1, + exportSchema = true, +) +@Keep +abstract class SubjectsDatabase : RoomDatabase() { + abstract val subjectDao: SubjectDao + + companion object { + fun getDatabase( + context: Context, + factory: SupportOpenHelperFactory, + dbName: String, + ): SubjectsDatabase { + val builder = Room.databaseBuilder(context, SubjectsDatabase::class.java, dbName) + if (BuildConfig.DB_ENCRYPTION) { + builder.openHelperFactory(factory) + } + return builder.build() + } + } +} diff --git a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectsDatabaseFactory.kt b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectsDatabaseFactory.kt new file mode 100644 index 0000000000..90be1fe0c9 --- /dev/null +++ b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectsDatabaseFactory.kt @@ -0,0 +1,57 @@ +package com.simprints.infra.enrolment.records.room.store + +import android.content.Context +import com.simprints.infra.logging.Simber +import com.simprints.infra.security.SecurityManager +import dagger.hilt.android.qualifiers.ApplicationContext +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import java.nio.charset.Charset +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.text.toByteArray + +@Singleton +class SubjectsDatabaseFactory @Inject constructor( + @ApplicationContext val ctx: Context, + private val secureLocalDbKeyProvider: SecurityManager, +) { + private lateinit var database: SubjectsDatabase + + /** + * Get the database instance. + * If the database is not initialized, it will be built. + */ + fun get(): SubjectsDatabase { + if (!::database.isInitialized) { + database = build() + } + return database + } + + @OptIn(ExperimentalStdlibApi::class) + private fun build(): SubjectsDatabase = try { + val key = getOrCreateKey() + val passphrase: ByteArray = key.toByteArray(Charset.forName("UTF-8")) + val factory = SupportOpenHelperFactory(passphrase) + SubjectsDatabase.getDatabase( + ctx, + factory, + DB_NAME, + ) + } catch (t: Throwable) { + Simber.e("Error creating subject database", t) + throw t + } + + private fun getOrCreateKey() = try { + secureLocalDbKeyProvider.getLocalDbKeyOrThrow(DB_NAME) + } catch (t: Throwable) { + t.message?.let { Simber.d(it) } + secureLocalDbKeyProvider.createLocalDatabaseKeyIfMissing(DB_NAME) + secureLocalDbKeyProvider.getLocalDbKeyOrThrow(DB_NAME) + }.value.decodeToString() + + companion object { + private const val DB_NAME = "db-subjects" + } +} diff --git a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/DbBiometricTemplate.kt b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/DbBiometricTemplate.kt new file mode 100644 index 0000000000..7590c579bd --- /dev/null +++ b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/DbBiometricTemplate.kt @@ -0,0 +1,41 @@ +package com.simprints.infra.enrolment.records.room.store.models + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate.Companion.FORMAT_COLUMN +import com.simprints.infra.enrolment.records.room.store.models.DbBiometricTemplate.Companion.TEMPLATE_TABLE_NAME +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.SUBJECT_ID_COLUMN + +@Entity( + tableName = TEMPLATE_TABLE_NAME, + foreignKeys = [ + ForeignKey( + entity = DbSubject::class, + parentColumns = [SUBJECT_ID_COLUMN], + childColumns = [SUBJECT_ID_COLUMN], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = [FORMAT_COLUMN, SUBJECT_ID_COLUMN]), + Index(value = [SUBJECT_ID_COLUMN]), + ], +) +@Suppress("ArrayInDataClass") +data class DbBiometricTemplate( + @PrimaryKey + val uuid: String = "", + val subjectId: String = "", + val identifier: Int? = null, // e.g the finger number or other identifier for the biometric + val templateData: ByteArray = byteArrayOf(), + val format: String = "", + val referenceId: String = "", + val modality: Int = Modality.FINGERPRINT.id, +) { + companion object { + const val TEMPLATE_TABLE_NAME = "DbBiometricTemplate" + const val FORMAT_COLUMN = "format" + } +} diff --git a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/DbSubject.kt b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/DbSubject.kt new file mode 100644 index 0000000000..76abb42418 --- /dev/null +++ b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/DbSubject.kt @@ -0,0 +1,40 @@ +package com.simprints.infra.enrolment.records.room.store.models + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.SUBJECT_TABLE_NAME +import java.util.UUID + +/** + * Represents a Subject entry in the local database. + * + */ +@Entity( + tableName = SUBJECT_TABLE_NAME, + indices = [ + Index(value = [DbSubject.PROJECT_ID_COLUMN, DbSubject.SUBJECT_ID_COLUMN]), + Index(value = [DbSubject.PROJECT_ID_COLUMN, DbSubject.MODULE_ID_COLUMN, DbSubject.SUBJECT_ID_COLUMN]), + Index(value = [DbSubject.PROJECT_ID_COLUMN, DbSubject.ATTENDANT_ID_COLUMN, DbSubject.SUBJECT_ID_COLUMN]), + ], +) +data class DbSubject( + @PrimaryKey + val subjectId: String = UUID.randomUUID().toString(), + val projectId: String = "", + val attendantId: String = "", + val moduleId: String = "", + val createdAt: Long? = 0, + val updatedAt: Long? = 0, +) { + /** + * Companion object holding constants for column names. + */ + companion object { + const val SUBJECT_TABLE_NAME = "DbSubject" + const val SUBJECT_ID_COLUMN = "subjectId" + const val PROJECT_ID_COLUMN = "projectId" + const val ATTENDANT_ID_COLUMN = "attendantId" + const val MODULE_ID_COLUMN = "moduleId" + } +} diff --git a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/Modality.kt b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/Modality.kt new file mode 100644 index 0000000000..4aa4944e19 --- /dev/null +++ b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/Modality.kt @@ -0,0 +1,8 @@ +package com.simprints.infra.enrolment.records.room.store.models + +enum class Modality( + val id: Int, +) { + FINGERPRINT(0), + FACE(1), +} diff --git a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/SubjectBiometrics.kt b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/SubjectBiometrics.kt new file mode 100644 index 0000000000..1ea2f722d2 --- /dev/null +++ b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/models/SubjectBiometrics.kt @@ -0,0 +1,14 @@ +package com.simprints.infra.enrolment.records.room.store.models + +import androidx.room.Embedded +import androidx.room.Relation +import com.simprints.infra.enrolment.records.room.store.models.DbSubject.Companion.SUBJECT_ID_COLUMN + +data class SubjectBiometrics( + @Embedded val subject: DbSubject, + @Relation( + parentColumn = SUBJECT_ID_COLUMN, + entityColumn = SUBJECT_ID_COLUMN, + ) + val biometricTemplates: List, +) diff --git a/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt b/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt index 367eb537c3..ef934bcf16 100644 --- a/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt +++ b/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt @@ -39,6 +39,7 @@ object LoggingConstants { SETTINGS, ALERT, REALM_DB, + ROOM_RECORDS_DB, DB_CORRUPTION, ENROLMENT, APP_SCOPE_ERROR, diff --git a/infra/test-tools/build.gradle.kts b/infra/test-tools/build.gradle.kts index 38c299764e..34b06d0078 100644 --- a/infra/test-tools/build.gradle.kts +++ b/infra/test-tools/build.gradle.kts @@ -16,7 +16,6 @@ dependencies { api(libs.testing.fragment) api(libs.testing.androidX.ext.junit) api(libs.testing.androidX.runner) - api(libs.testing.androidX.navigation) api(libs.testing.navigation) api(libs.testing.hilt) diff --git a/settings.gradle.kts b/settings.gradle.kts index f193228fe7..2cfa9bdb30 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -134,6 +134,7 @@ include( ":infra:config-sync", ":infra:enrolment-records:repository", ":infra:enrolment-records:realm-store", + ":infra:enrolment-records:room-store", ":infra:images", ":infra:license", ":infra:logging",