diff --git a/.github/workflows/pipeline-deploy-to-internal.yml b/.github/workflows/pipeline-deploy-to-internal.yml index b95d4c3704..dfd54fda62 100644 --- a/.github/workflows/pipeline-deploy-to-internal.yml +++ b/.github/workflows/pipeline-deploy-to-internal.yml @@ -13,8 +13,9 @@ jobs: build-internal-aab: uses: ./.github/workflows/reusable-build-apk.yml secrets: inherit + needs: get-version with: - build-environment: internal + build-environment: Internal version-name: ${{ needs.get-version.outputs.version-name }} run-app-sweep: @@ -28,21 +29,10 @@ jobs: deploy-internal-build: uses: ./.github/workflows/reusable-promote-artifact.yml + secrets: inherit with: - deployment-track: internal - upload-artifact: ${{ needs.build-internal-aab.outputs.build-artifact }} - mapping-file: ${{ needs.build-internal-aab.outputs.optional-mapping-file }} - needs: - - build-internal-aab - - run-app-sweep - - deploy-alpha-build: - uses: ./.github/workflows/reusable-promote-artifact.yml - with: - deployment-track: alpha + deployment-track: Internal upload-artifact: ${{ needs.build-internal-aab.outputs.build-artifact }} mapping-file: ${{ needs.build-internal-aab.outputs.optional-mapping-file }} needs: - build-internal-aab - - run-app-sweep - - deploy-internal-build diff --git a/.github/workflows/pipeline-deploy-to-staging.yml b/.github/workflows/pipeline-deploy-to-staging.yml index a87939a719..9a53cb5a8a 100644 --- a/.github/workflows/pipeline-deploy-to-staging.yml +++ b/.github/workflows/pipeline-deploy-to-staging.yml @@ -13,8 +13,9 @@ jobs: build-staging-apk: uses: ./.github/workflows/reusable-build-apk.yml secrets: inherit + needs: get-version with: - build-environment: staging + build-environment: Staging version-name: ${{ needs.get-version.outputs.version-name }} deploy-to-firebase: @@ -23,5 +24,5 @@ jobs: needs: - build-staging-apk with: - build-environment: staging + build-environment: Staging upload-artifact: ${{ needs.build-staging-apk.outputs.build-artifact }} diff --git a/.github/workflows/reusable-app-sweep.yml b/.github/workflows/reusable-app-sweep.yml index 15c7c72f7a..1181d7c507 100644 --- a/.github/workflows/reusable-app-sweep.yml +++ b/.github/workflows/reusable-app-sweep.yml @@ -16,7 +16,7 @@ jobs: name: App Sweep Security Scan runs-on: ubuntu-latest timeout-minutes: ${{ fromJSON(vars.JOB_TIMEOUT_MINUTES) }} - environment: internal + environment: Internal steps: - name: Download AAB artifact diff --git a/.github/workflows/reusable-deploy-to-firebase-distribution.yml b/.github/workflows/reusable-deploy-to-firebase-distribution.yml index 27eebc1f01..c602cb5bd0 100644 --- a/.github/workflows/reusable-deploy-to-firebase-distribution.yml +++ b/.github/workflows/reusable-deploy-to-firebase-distribution.yml @@ -61,7 +61,7 @@ jobs: console_url="$(echo "$output" | grep -o 'https://console\.firebase\.google\.com[^[:space:]]*')" # Expose console_url as a step output using $GITHUB_OUTPUT - echo "console_url=$console_url" >> "$GITHUB_OUTPUT" + echo "console_url=$console_url" >> $GITHUB_OUTPUT - name: Distribution Summary run: | diff --git a/.github/workflows/reusable-get-version-name.yml b/.github/workflows/reusable-get-version-name.yml index c0dfe775e4..d57045650d 100644 --- a/.github/workflows/reusable-get-version-name.yml +++ b/.github/workflows/reusable-get-version-name.yml @@ -31,7 +31,7 @@ jobs: run: | if [ "${{ inputs.version-source }}" = "branch" ]; then last_part="${GITHUB_REF_NAME##*/}" - echo "version-name=$last_part" >> "$GITHUB_OUTPUT" + echo "version-name=$last_part" >> $GITHUB_OUTPUT elif [ "${{ inputs.version-source }}" = "internal" ]; then VERSION_NAME=$(grep 'set("VERSION_NAME"' build-logic/build_properties.gradle.kts \ | sed -E 's/.*set\("VERSION_NAME",[[:space:]]*"([^"]+)".*/\1/') diff --git a/.github/workflows/reusable-promote-artifact.yml b/.github/workflows/reusable-promote-artifact.yml index f60942d7ac..4a28630207 100644 --- a/.github/workflows/reusable-promote-artifact.yml +++ b/.github/workflows/reusable-promote-artifact.yml @@ -46,35 +46,47 @@ jobs: - name: Set vars run: | - if [ "${{inputs.deployment-track}}" == "Internal" ]; then - STATUS="completed" - USER_FRACTION=1.0 - TRACK="qa" - elif [ "${{inputs.deployment-track}}" == "Alpha" ]; then - STATUS="completed" - USER_FRACTION=1.0 - TRACK="alpha" - elif [ "${{inputs.deployment-track}}" == "Prod-25-Percent-Rollout" ]; then - STATUS="inProgress" - USER_FRACTION=0.25 - TRACK="production" - elif [ "${{inputs.deployment-track}}" == "Prod-50-Percent-Rollout" ]; then - STATUS="inProgress" - USER_FRACTION=0.5 - TRACK="production" - elif [ "${{inputs.deployment-track}}" == "Prod-100-Percent-Rollout" ]; then - STATUS="completed" - USER_FRACTION=1.0 - TRACK="production" + if [ "${{ inputs.deployment-track }}" == "Internal" ]; then + echo "STATUS=completed" >> $GITHUB_ENV + echo "USER_FRACTION=1.0" >> $GITHUB_ENV + echo "TRACK=internal" >> $GITHUB_ENV + elif [ "${{ inputs.deployment-track }}" == "Alpha" ]; then + echo "STATUS=completed" >> $GITHUB_ENV + echo "USER_FRACTION=1.0" >> $GITHUB_ENV + echo "TRACK=alpha" >> $GITHUB_ENV + elif [ "${{ inputs.deployment-track }}" == "Prod-25-Percent-Rollout" ]; then + echo "STATUS=inProgress" >> $GITHUB_ENV + echo "USER_FRACTION=0.25" >> $GITHUB_ENV + echo "TRACK=production" >> $GITHUB_ENV + elif [ "${{ inputs.deployment-track }}" == "Prod-50-Percent-Rollout" ]; then + echo "STATUS=inProgress" >> $GITHUB_ENV + echo "USER_FRACTION=0.5" >> $GITHUB_ENV + echo "TRACK=production" >> $GITHUB_ENV + elif [ "${{ inputs.deployment-track }}" == "Prod-100-Percent-Rollout" ]; then + echo "STATUS=completed" >> $GITHUB_ENV + echo "USER_FRACTION=1.0" >> $GITHUB_ENV + echo "TRACK=production" >> $GITHUB_ENV fi - - name: Publish app to track - uses: r0adkll/upload-google-play@v1 - with: - serviceAccountJsonPlainText: ${{secrets.GOOGLE_API_KEY_JSON}} - packageName: com.simprints.id - releaseFiles: ${{ inputs.upload-artifact }} - track: $TRACK - status: $STATUS - userFraction: $USER_FRACTION - mappingFile: ${{ inputs.mapping-file }} + + - name: Publish app (Completed Release) + if: env.STATUS == 'completed' + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GOOGLE_API_KEY_JSON }} + packageName: com.simprints.id + releaseFiles: ${{ inputs.upload-artifact }} + track: ${{ env.TRACK }} + mappingFile: ${{ inputs.mapping-file }} + + - name: Publish app (Rollout Release) + if: env.STATUS == 'inProgress' + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GOOGLE_API_KEY_JSON }} + packageName: com.simprints.id + releaseFiles: ${{ inputs.upload-artifact }} + track: ${{ env.TRACK }} + status: ${{ env.STATUS }} + userFraction: ${{ env.USER_FRACTION }} + mappingFile: ${{ inputs.mapping-file }} diff --git a/.github/workflows/reusable-sonar-scan.yml b/.github/workflows/reusable-sonar-scan.yml index f1a6c758a1..c53c0a1aa6 100644 --- a/.github/workflows/reusable-sonar-scan.yml +++ b/.github/workflows/reusable-sonar-scan.yml @@ -69,4 +69,11 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - ./gradlew sonar -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=3600 --info + # Check if the event is a pull request + if [ "${{ github.event_name }}" == "pull_request" ]; then + # Run SonarQube scan without branch name param as sonarqube will detect PR branch + ./gradlew sonar -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=3600 --info + else + # Run SonarQube scan with branch name param + ./gradlew sonar -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=3600 -Dsonar.branch.name=${{ github.ref_name }} --info + fi diff --git a/build-logic/build_properties.gradle.kts b/build-logic/build_properties.gradle.kts index 0edcb6e1b3..50da31676a 100644 --- a/build-logic/build_properties.gradle.kts +++ b/build-logic/build_properties.gradle.kts @@ -17,7 +17,7 @@ extra.apply { * Dev version >= 2024.2.2 is required for float quality thresholds * Dev version >= 2024.3.0 is required to receive configuration ID */ - set("VERSION_NAME", "2024.3.0") + set("VERSION_NAME", "2025.1.0") /** * Build type. The version code describes which build type was used for the build. diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt index 3a0c3f2a51..23e1082096 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt @@ -18,7 +18,6 @@ import com.simprints.face.capture.usecases.SaveFaceImageUseCase import com.simprints.face.capture.usecases.SimpleCaptureEventReporter import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.models.experimental import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.license.LicenseRepository import com.simprints.infra.license.LicenseStatus @@ -56,6 +55,7 @@ internal class FaceCaptureViewModel @Inject constructor( // Updated in live feedback screen var attemptNumber: Int = 0 var samplesToCapture = 1 + var initialised = false var shouldCheckCameraPermissions = AtomicBoolean(true) @@ -90,6 +90,11 @@ internal class FaceCaptureViewModel @Inject constructor( } fun initFaceBioSdk(activity: Activity) = viewModelScope.launch { + if (initialised) { + Simber.i("Face bio SDK already initialised", tag = FACE_CAPTURE) + return@launch + } + Simber.i("Starting face capture flow", tag = FACE_CAPTURE) val licenseVendor = Vendor.RankOne @@ -124,10 +129,9 @@ internal class FaceCaptureViewModel @Inject constructor( saveLicenseCheckEvent(licenseVendor, licenseStatus) } - fun setupAutoCapture() = - viewModelScope.launch { - _isAutoCaptureEnabled.postValue(isUsingAutoCapture()) - } + fun setupAutoCapture() = viewModelScope.launch { + _isAutoCaptureEnabled.postValue(isUsingAutoCapture()) + } private suspend fun initialize( activity: Activity, @@ -139,6 +143,7 @@ internal class FaceCaptureViewModel @Inject constructor( // This is should reported as an error return LicenseStatus.ERROR } + initialised = true return LicenseStatus.VALID } diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt index 2c32b45e0f..0e042b5535 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt @@ -11,7 +11,6 @@ import com.simprints.face.capture.usecases.SimpleCaptureEventReporter import com.simprints.face.infra.basebiosdk.initialization.FaceBioSdkInitializer import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.FaceConfiguration.ImageSavingStrategy -import com.simprints.infra.config.store.models.experimental import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.license.LicenseRepository import com.simprints.infra.license.LicenseStatus @@ -161,7 +160,7 @@ class FaceCaptureViewModelTest { } @Test - fun `test initFaceBioSdk should initialize faceBioSdk`() { + fun `test initFaceBioSdk should initialize faceBioSdk only once`() { // Given val license = "license" every { faceBioSdkInitializer.tryInitWithLicense(any(), license) } returns true @@ -173,8 +172,11 @@ class FaceCaptureViewModelTest { // When viewModel.initFaceBioSdk(mockk()) + viewModel.initFaceBioSdk(mockk()) + viewModel.initFaceBioSdk(mockk()) + // Then - coVerify { faceBioSdkInitializer.tryInitWithLicense(any(), license) } + coVerify(exactly = 1) { faceBioSdkInitializer.tryInitWithLicense(any(), license) } assertThat(licenseStatusSlot.captured).isEqualTo(LicenseStatus.VALID) } diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt index e713cc0954..f7e8908bc3 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt @@ -74,7 +74,8 @@ internal class LibSimprintsResponseMapper @Inject constructor() { guid = response.matchResult.guid, ), ) - putBoolean(Constants.SIMPRINTS_VERIFICATION_SUCCESS, response.matchResult.verificationSuccess == true) + response.matchResult.verificationSuccess + ?.let { putBoolean(Constants.SIMPRINTS_VERIFICATION_SUCCESS, it) } } else -> putString( @@ -83,7 +84,7 @@ internal class LibSimprintsResponseMapper @Inject constructor() { guid = response.matchResult.guid, confidence = response.matchResult.confidenceScore.toFloat(), confidenceBand = ConfidenceBand.valueOf(response.matchResult.matchConfidence.name), - isSuccess = response.matchResult.verificationSuccess == true, + isSuccess = response.matchResult.verificationSuccess, ).toJson(), ) } diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt index da6b742589..355f117d9f 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt @@ -1,5 +1,9 @@ package com.simprints.feature.clientapi.usecases +import com.fasterxml.jackson.databind.module.SimpleModule +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.domain.tokenization.serialization.TokenizationClassNameDeserializer +import com.simprints.core.domain.tokenization.serialization.TokenizationClassNameSerializer import com.simprints.core.tools.json.JsonHelper import com.simprints.core.tools.utils.EncodingUtils import com.simprints.infra.config.store.models.canCoSyncAllData @@ -34,7 +38,7 @@ internal class GetEnrolmentCreationEventForSubjectUseCase @Inject constructor( ?.fromSubjectToEnrolmentCreationEvent() ?: return null - return jsonHelper.toJson(CoSyncEnrolmentRecordEvents(listOf(recordCreationEvent))) + return jsonHelper.toJson(CoSyncEnrolmentRecordEvents(listOf(recordCreationEvent)), coSyncSerializationModule) } private fun Subject.fromSubjectToEnrolmentCreationEvent() = EnrolmentRecordCreationEvent( @@ -44,4 +48,11 @@ internal class GetEnrolmentCreationEventForSubjectUseCase @Inject constructor( attendantId, EnrolmentRecordCreationEvent.buildBiometricReferences(fingerprintSamples, faceSamples, encoder), ) + + companion object { + val coSyncSerializationModule = SimpleModule().apply { + addSerializer(TokenizableString::class.java, TokenizationClassNameSerializer()) + addDeserializer(TokenizableString::class.java, TokenizationClassNameDeserializer()) + } + } } diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt index 951930e4d2..2bcf3727f3 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt @@ -153,7 +153,7 @@ class LibSimprintsResponseMapperTest { assertThat(extraVerification?.tier).isEqualTo(LegacyTier.TIER_1) assertThat(extraVerification?.getConfidence()).isEqualTo(50) assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() - assertThat(extras.getBoolean(Constants.SIMPRINTS_VERIFICATION_SUCCESS)).isFalse() // Default value + assertThat(extras.keySet()).doesNotContain(Constants.SIMPRINTS_VERIFICATION_SUCCESS) } @Test @@ -179,6 +179,7 @@ class LibSimprintsResponseMapperTest { assertThat(extraVerification?.tier).isEqualTo(LegacyTier.TIER_1) assertThat(extraVerification?.getConfidence()).isEqualTo(50) assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() + assertThat(extras.keySet()).contains(Constants.SIMPRINTS_VERIFICATION_SUCCESS) assertThat(extras.getBoolean(Constants.SIMPRINTS_VERIFICATION_SUCCESS)).isEqualTo(false) } @@ -204,7 +205,6 @@ class LibSimprintsResponseMapperTest { assertThat(extraVerification?.guid).isEqualTo("guid") assertThat(extraVerification?.tier).isEqualTo(LegacyTier.TIER_1) assertThat(extraVerification?.getConfidence()).isEqualTo(50) - // assertThat(extraVerification?.isSuccess).isTrue() assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() assertThat(extras.getBoolean(Constants.SIMPRINTS_VERIFICATION_SUCCESS)).isTrue() } @@ -235,6 +235,32 @@ class LibSimprintsResponseMapperTest { assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() } + @Test + fun `correctly maps verify response without success after LibSimprints refactoring`() { + val extras = mapper( + ActionResponse.VerifyActionResponse( + actionIdentifier = VerifyActionFactory + .getIdentifier() + .copy(contractVersion = VersionsList.INITIAL_REWORK), + sessionId = "sessionId", + matchResult = AppMatchResult( + guid = "guid", + confidenceScore = 50, + matchConfidence = AppMatchConfidence.HIGH, + verificationSuccess = null, + ), + ), + ) + + assertThat(extras.getString(Constants.SIMPRINTS_SESSION_ID)).isEqualTo("sessionId") + assertThat(extras.getString(Constants.SIMPRINTS_VERIFICATION)).isEqualTo( + """ + {"guid":"guid","confidenceBand":"HIGH","confidence":50} + """.trimIndent(), + ) + assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() + } + @Test fun `correctly maps exit form response`() { val extras = mapper( diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCaseTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCaseTest.kt index 60285cb7d4..3d0b70f208 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCaseTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCaseTest.kt @@ -98,7 +98,7 @@ class GetEnrolmentCreationEventForSubjectUseCaseTest { val result = useCase("projectId", "subjectId") coVerify { enrolmentRecordRepository.load(any()) } - coVerify { jsonHelper.toJson(any()) } + coVerify { jsonHelper.toJson(any(), any()) } assertThat(result).isNotNull() } } diff --git a/feature/consent/src/main/java/com/simprints/feature/consent/screens/privacy/PrivacyNoticeViewModel.kt b/feature/consent/src/main/java/com/simprints/feature/consent/screens/privacy/PrivacyNoticeViewModel.kt index 7a3ca96cab..03076a585d 100644 --- a/feature/consent/src/main/java/com/simprints/feature/consent/screens/privacy/PrivacyNoticeViewModel.kt +++ b/feature/consent/src/main/java/com/simprints/feature/consent/screens/privacy/PrivacyNoticeViewModel.kt @@ -14,9 +14,13 @@ import com.simprints.infra.config.store.models.PrivacyNoticeResult.FailedBecause import com.simprints.infra.config.store.models.PrivacyNoticeResult.InProgress import com.simprints.infra.config.store.models.PrivacyNoticeResult.Succeed import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.logging.Simber import com.simprints.infra.network.ConnectivityTracker import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -44,18 +48,31 @@ internal class PrivacyNoticeViewModel @Inject constructor( } fun retrievePrivacyNotice() = viewModelScope.launch { - val deviceConfiguration = configManager.getDeviceConfiguration() - configManager - .getPrivacyNotice( - authStore.signedInProjectId, - deviceConfiguration.language, - ).map { it.toPrivacyNoticeViewState() } - .catch { - it.printStackTrace() - PrivacyNoticeState.ConsentNotAvailable - }.collect { _viewState.postValue(it) } + val projectId = authStore.signedInProjectId + val deviceLanguage = configManager.getDeviceConfiguration().language + val defaultLanguage = configManager.getProjectConfiguration().general.defaultLanguage + + attemptDownloadingLanguage(projectId, deviceLanguage) + .flatMapLatest { + if (it is PrivacyNoticeState.ConsentNotAvailable) { + Simber.i("Privacy notice in ($deviceLanguage) not available") + attemptDownloadingLanguage(projectId, defaultLanguage) + } else { + flowOf(it) + } + }.catch { + Simber.i("Notice download failed", it) + emit(PrivacyNoticeState.ConsentNotAvailable) + }.collect { + _viewState.postValue(it) + } } + private fun attemptDownloadingLanguage( + projectId: String, + deviceLanguage: String, + ): Flow = configManager.getPrivacyNotice(projectId, deviceLanguage).map { it.toPrivacyNoticeViewState() } + private fun PrivacyNoticeResult.toPrivacyNoticeViewState(): PrivacyNoticeState = when (this) { is Succeed -> PrivacyNoticeState.ConsentAvailable(consent) is InProgress -> PrivacyNoticeState.DownloadInProgress diff --git a/feature/consent/src/test/java/com/simprints/feature/consent/screens/privacy/PrivacyNoticeViewModelTest.kt b/feature/consent/src/test/java/com/simprints/feature/consent/screens/privacy/PrivacyNoticeViewModelTest.kt index b1ce5722b7..706c139eda 100644 --- a/feature/consent/src/test/java/com/simprints/feature/consent/screens/privacy/PrivacyNoticeViewModelTest.kt +++ b/feature/consent/src/test/java/com/simprints/feature/consent/screens/privacy/PrivacyNoticeViewModelTest.kt @@ -22,7 +22,8 @@ import org.junit.Test internal class PrivacyNoticeViewModelTest { companion object { private const val PROJECT_ID = "projectId" - private const val LANGUAGE = "en" + private const val DEVICE_LANGUAGE = "en" + private const val DEFAULT_LANGUAGE = "fr" } @get:Rule @@ -46,7 +47,8 @@ internal class PrivacyNoticeViewModelTest { fun setUp() { MockKAnnotations.init(this, relaxed = true) - coEvery { configManager.getDeviceConfiguration() } returns DeviceConfiguration(LANGUAGE, listOf(), "") + coEvery { configManager.getDeviceConfiguration() } returns DeviceConfiguration(DEVICE_LANGUAGE, listOf(), "") + coEvery { configManager.getProjectConfiguration().general.defaultLanguage } returns DEFAULT_LANGUAGE every { authStore.signedInProjectId } returns PROJECT_ID privacyNoticeViewModel = PrivacyNoticeViewModel( @@ -58,8 +60,8 @@ internal class PrivacyNoticeViewModelTest { @Test fun `retrievePrivacyNotice should return DownloadInProgress when trying download`() = runTest { - coEvery { configManager.getPrivacyNotice(PROJECT_ID, LANGUAGE) } returns flowOf( - PrivacyNoticeResult.InProgress(LANGUAGE), + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEVICE_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress(DEVICE_LANGUAGE), ) val privacyNoticeLiveData = privacyNoticeViewModel.viewState @@ -71,9 +73,9 @@ internal class PrivacyNoticeViewModelTest { @Test fun `retrievePrivacyNotice should return ContentAvailable when success received`() = runTest { - coEvery { configManager.getPrivacyNotice(PROJECT_ID, LANGUAGE) } returns flowOf( - PrivacyNoticeResult.InProgress(LANGUAGE), - PrivacyNoticeResult.Succeed(LANGUAGE, "some long consent"), + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEVICE_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress(DEVICE_LANGUAGE), + PrivacyNoticeResult.Succeed(DEVICE_LANGUAGE, "some long consent"), ) val privacyNoticeLiveData = privacyNoticeViewModel.viewState @@ -84,24 +86,28 @@ internal class PrivacyNoticeViewModelTest { } @Test - fun `retrievePrivacyNotice should return ConsentNotAvailable when Failed received`() = runTest { - coEvery { configManager.getPrivacyNotice(PROJECT_ID, LANGUAGE) } returns flowOf( - PrivacyNoticeResult.InProgress(LANGUAGE), - PrivacyNoticeResult.Failed(LANGUAGE, Throwable()), + fun `retrievePrivacyNotice should attempt default language when Failed received with initial`() = runTest { + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEVICE_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress(DEVICE_LANGUAGE), + PrivacyNoticeResult.Failed(DEVICE_LANGUAGE, Throwable()), + ) + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEFAULT_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress(DEFAULT_LANGUAGE), + PrivacyNoticeResult.Succeed(DEFAULT_LANGUAGE, "some long consent"), ) val privacyNoticeLiveData = privacyNoticeViewModel.viewState privacyNoticeViewModel.retrievePrivacyNotice() val value = privacyNoticeLiveData.getOrAwaitValue() - Truth.assertThat(value).isInstanceOf(PrivacyNoticeState.ConsentNotAvailable::class.java) + Truth.assertThat(value).isInstanceOf(PrivacyNoticeState.ConsentAvailable::class.java) } @Test fun `retrievePrivacyNotice should return BackendMaintenance when FailedBecauseBackendMaintenance received`() = runTest { - coEvery { configManager.getPrivacyNotice(PROJECT_ID, LANGUAGE) } returns flowOf( - PrivacyNoticeResult.InProgress(LANGUAGE), - PrivacyNoticeResult.FailedBecauseBackendMaintenance(LANGUAGE, Throwable()), + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEVICE_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress(DEVICE_LANGUAGE), + PrivacyNoticeResult.FailedBecauseBackendMaintenance(DEVICE_LANGUAGE, Throwable()), ) val privacyNoticeLiveData = privacyNoticeViewModel.viewState @@ -111,11 +117,30 @@ internal class PrivacyNoticeViewModelTest { Truth.assertThat(value).isInstanceOf(PrivacyNoticeState.BackendMaintenance::class.java) } + @Test + fun `retrievePrivacyNotice should return BackendMaintenance when FailedBecauseBackendMaintenance receivedon default language`() = + runTest { + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEVICE_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress(DEVICE_LANGUAGE), + PrivacyNoticeResult.Failed(DEVICE_LANGUAGE, Throwable()), + ) + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEFAULT_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress(DEFAULT_LANGUAGE), + PrivacyNoticeResult.FailedBecauseBackendMaintenance(DEVICE_LANGUAGE, Throwable()), + ) + + val privacyNoticeLiveData = privacyNoticeViewModel.viewState + privacyNoticeViewModel.retrievePrivacyNotice() + + val value = privacyNoticeLiveData.getOrAwaitValue() + Truth.assertThat(value).isInstanceOf(PrivacyNoticeState.BackendMaintenance::class.java) + } + @Test fun `retrievePrivacyNotice should return BackendMaintenance with estimation when FailedBecauseBackendMaintenance received`() = runTest { - coEvery { configManager.getPrivacyNotice(PROJECT_ID, LANGUAGE) } returns flowOf( - PrivacyNoticeResult.InProgress(LANGUAGE), - PrivacyNoticeResult.FailedBecauseBackendMaintenance(LANGUAGE, Throwable(), 1000L), + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEVICE_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress(DEVICE_LANGUAGE), + PrivacyNoticeResult.FailedBecauseBackendMaintenance(DEVICE_LANGUAGE, Throwable(), 1000L), ) val privacyNoticeLiveData = privacyNoticeViewModel.viewState @@ -129,7 +154,11 @@ internal class PrivacyNoticeViewModelTest { @Test fun `downloadPressed should retrieve notice when online`() = runTest { every { connectivityTracker.isConnected() } returns true - coEvery { configManager.getPrivacyNotice(PROJECT_ID, LANGUAGE) } returns flowOf(PrivacyNoticeResult.InProgress(LANGUAGE)) + coEvery { configManager.getPrivacyNotice(PROJECT_ID, DEVICE_LANGUAGE) } returns flowOf( + PrivacyNoticeResult.InProgress( + DEVICE_LANGUAGE, + ), + ) val privacyNoticeLiveData = privacyNoticeViewModel.viewState val showOfflineLiveData = privacyNoticeViewModel.showOffline diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56d660f871..d502c1c04a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,7 +63,7 @@ circleImageView_version = "3.1.0" kermit_version = "2.0.5" zip4j_version = "2.11.5" -libsimprints_version = "2025.1.2-SNAPSHOT" +libsimprints_version = "2025.1.3-SNAPSHOT" simmatcher_version = "1.2.0" roc_wrapper_version = "1.23.0" roc_wrapper-v3_version = "3.1.0" diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt index 0c0d97f498..0c763c2b79 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt @@ -25,7 +25,7 @@ interface ConfigRepository { suspend fun clearData() - suspend fun getPrivacyNotice( + fun getPrivacyNotice( projectId: String, language: String, ): Flow diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt index 5bee1ec1c8..8f6a72f276 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt @@ -79,7 +79,7 @@ internal class ConfigRepositoryImpl @Inject constructor( localDataSource.deletePrivacyNotices() } - override suspend fun getPrivacyNotice( + override fun getPrivacyNotice( projectId: String, language: String, ): Flow = flow { @@ -102,14 +102,14 @@ internal class ConfigRepositoryImpl @Inject constructor( is TokenizableString.Raw -> tokenizationProcessor.encrypt( decrypted = moduleId, tokenKeyType = TokenKeyType.ModuleId, - project = project + project = project, ) is TokenizableString.Tokenized -> moduleId } - } - ) - ) + }, + ), + ), ) } diff --git a/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt b/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt index 40e8287b89..7cabf1b198 100644 --- a/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt +++ b/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt @@ -46,16 +46,16 @@ class ConfigManager @Inject constructor( } } - fun watchProjectConfiguration(): Flow = - configRepository.watchProjectConfiguration() - .onStart { getProjectConfiguration() } // to invoke download if empty + fun watchProjectConfiguration(): Flow = configRepository + .watchProjectConfiguration() + .onStart { getProjectConfiguration() } // to invoke download if empty suspend fun getDeviceConfiguration(): DeviceConfiguration = configRepository.getDeviceConfiguration() suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) = configRepository.updateDeviceConfiguration(update) - suspend fun getPrivacyNotice( + fun getPrivacyNotice( projectId: String, language: String, ): Flow = configRepository.getPrivacyNotice( diff --git a/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/commcare/CommCareIdentityDataSource.kt b/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/commcare/CommCareIdentityDataSource.kt index 6de2f533be..0c8d4b9baf 100644 --- a/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/commcare/CommCareIdentityDataSource.kt +++ b/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/commcare/CommCareIdentityDataSource.kt @@ -21,6 +21,7 @@ import com.simprints.infra.enrolment.records.store.domain.models.FaceIdentity import com.simprints.infra.enrolment.records.store.domain.models.FingerprintIdentity import com.simprints.infra.enrolment.records.store.domain.models.SubjectQuery import com.simprints.infra.enrolment.records.store.usecases.CompareImplicitTokenizedStringsUseCase +import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordCreationEventDeserializer import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEvents import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent import com.simprints.infra.events.event.domain.models.subject.FaceReference @@ -40,14 +41,6 @@ internal class CommCareIdentityDataSource @Inject constructor( @ApplicationContext private val context: Context, @DispatcherIO private val dispatcher: CoroutineDispatcher, ) : IdentityDataSource { - 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" - } - private fun getCaseMetadataUri(packageName: String): Uri = Uri.parse("content://$packageName.case/casedb/case") private fun getCaseDataUri(packageName: String): Uri = Uri.parse("content://$packageName.case/casedb/data") @@ -106,7 +99,9 @@ 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)) + enrolmentRecordCreationEvents.addAll( + loadEnrolmentRecordCreationEvents(caseId, callerPackageName, query, project), + ) onCandidateLoaded() } } while (caseMetadataCursor.moveToNext() && caseMetadataCursor.position < range.last) @@ -160,7 +155,7 @@ internal class CommCareIdentityDataSource @Inject constructor( caseId: String, callerPackageName: String, query: SubjectQuery, - project: Project + project: Project, ): List { // Access Case Data Listing for the caseId val caseDataUri = getCaseDataUri(callerPackageName).buildUpon().appendPath(caseId).build() @@ -175,26 +170,45 @@ internal class CommCareIdentityDataSource @Inject constructor( coSyncEnrolmentRecordEvents ?.events ?.filterIsInstance() - ?.filterNot { event -> + ?.filter { event -> // [MS-852] Plain strings from CommCare might be tokenized or untokenized. The only way to properly compare them // is by trying to decrypt the values to check if already tokenized, and then compare the values - (query.subjectId != null && query.subjectId != event.payload.subjectId) || - !compareImplicitTokenizedStringsUseCase( - query.attendantId, - event.payload.attendantId.value, - TokenKeyType.AttendantId, - project - ) || - !compareImplicitTokenizedStringsUseCase( - query.moduleId, - event.payload.moduleId.value, - TokenKeyType.ModuleId, - project - ) + isSubjectIdNullOrMatching(query, event) && + isAttendantIdNullOrMatching(query, event, project) && + isModuleIdNullOrMatching(query, event, project) } }.orEmpty() } + private fun isSubjectIdNullOrMatching( + query: SubjectQuery, + event: EnrolmentRecordCreationEvent, + ): Boolean = query.subjectId == null || query.subjectId == event.payload.subjectId + + private fun isAttendantIdNullOrMatching( + query: SubjectQuery, + event: EnrolmentRecordCreationEvent, + project: Project, + ): Boolean = query.attendantId == null || + compareImplicitTokenizedStringsUseCase( + query.attendantId, + event.payload.attendantId, + TokenKeyType.AttendantId, + project, + ) + + private fun isModuleIdNullOrMatching( + query: SubjectQuery, + event: EnrolmentRecordCreationEvent, + project: Project, + ): Boolean = query.moduleId == null || + compareImplicitTokenizedStringsUseCase( + query.moduleId, + event.payload.moduleId, + TokenKeyType.ModuleId, + project, + ) + private fun getSubjectActionsValue(caseDataCursor: Cursor): String { while (caseDataCursor.moveToNext()) { val key = caseDataCursor.getString(caseDataCursor.getColumnIndexOrThrow(COLUMN_DATUM_ID)) @@ -227,6 +241,10 @@ internal class CommCareIdentityDataSource @Inject constructor( TokenizableString::class.java, TokenizationClassNameDeserializer(), ) + addDeserializer( + EnrolmentRecordCreationEvent::class.java, + CoSyncEnrolmentRecordCreationEventDeserializer(), + ) } override suspend fun count( @@ -244,4 +262,12 @@ internal class CommCareIdentityDataSource @Inject constructor( )?.use { caseMetadataCursor -> count = caseMetadataCursor.count } count } + + 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-store/src/main/java/com/simprints/infra/enrolment/records/store/local/EnrolmentRecordLocalDataSourceImpl.kt b/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/local/EnrolmentRecordLocalDataSourceImpl.kt index ecbcec8f92..d98f0c374e 100644 --- a/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/local/EnrolmentRecordLocalDataSourceImpl.kt +++ b/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/local/EnrolmentRecordLocalDataSourceImpl.kt @@ -184,10 +184,10 @@ internal class EnrolmentRecordLocalDataSourceImpl @Inject constructor( ) } if (query.attendantId != null) { - realmQuery = realmQuery.query("$USER_ID_FIELD == $0", query.attendantId) + realmQuery = realmQuery.query("$USER_ID_FIELD == $0", query.attendantId.value) } if (query.moduleId != null) { - realmQuery = realmQuery.query("$MODULE_ID_FIELD == $0", query.moduleId) + realmQuery = realmQuery.query("$MODULE_ID_FIELD == $0", query.moduleId.value) } if (query.fingerprintSampleFormat != null) { realmQuery = realmQuery.query( diff --git a/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/usecases/CompareImplicitTokenizedStringsUseCase.kt b/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/usecases/CompareImplicitTokenizedStringsUseCase.kt index 6ea6618c30..4291bf7412 100644 --- a/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/usecases/CompareImplicitTokenizedStringsUseCase.kt +++ b/infra/enrolment-records-store/src/main/java/com/simprints/infra/enrolment/records/store/usecases/CompareImplicitTokenizedStringsUseCase.kt @@ -9,7 +9,7 @@ import com.simprints.infra.config.store.tokenization.TokenizationProcessor import javax.inject.Inject /** - * Use case that checks two plain strings values but considers that the tokenization state of one of the values + * Use case that checks two TokenizableStrings but considers that the tokenization state of one of the values * might differ from another. In this case this use tries to bring both strings to the same tokenization state * and compare their values. * Example: @@ -17,13 +17,16 @@ import javax.inject.Inject * s2 = 'AWcDe/==seF1LkcF4' - tokenized value (result of tokenizing the 'abc' value) * * Even though plain string values are different, they represent the same entity. s1 is going to be encrypted and compared to the s2. + * + * Given the implementation of TokenizationClassNameDeserializer - if a value is TokenizableString.Tokenized, we can be sure that it is tokenized. + * Only deserialized TokenizableString.Raw values have uncertain tokenization state. */ class CompareImplicitTokenizedStringsUseCase @Inject constructor( private val tokenizationProcessor: TokenizationProcessor, ) { operator fun invoke( s1: TokenizableString?, - s2: String, + s2: TokenizableString, tokenKeyType: TokenKeyType, project: Project, ): Boolean = when { @@ -37,33 +40,23 @@ class CompareImplicitTokenizedStringsUseCase @Inject constructor( project: Project, ): TokenizableString = when (s) { is TokenizableString.Tokenized -> s - is TokenizableString.Raw -> tokenizationProcessor.encrypt( - decrypted = s, - tokenKeyType = tokenKeyType, - project = project, - ) - } - - private fun ensureTokenized( - s: String, - tokenKeyType: TokenKeyType, - project: Project, - ): TokenizableString { - val isAlreadyTokenized = tokenizationProcessor.decrypt( - encrypted = s.asTokenizableEncrypted(), - tokenKeyType = tokenKeyType, - project = project, - logError = false, - ) is TokenizableString.Raw - - return if (isAlreadyTokenized) { - s.asTokenizableEncrypted() - } else { - tokenizationProcessor.encrypt( - decrypted = s.asTokenizableRaw(), + is TokenizableString.Raw -> { + val isAlreadyTokenized = tokenizationProcessor.decrypt( + encrypted = s.value.asTokenizableEncrypted(), tokenKeyType = tokenKeyType, project = project, - ) + logError = false, + ) is TokenizableString.Raw + + if (isAlreadyTokenized) { + s.value.asTokenizableEncrypted() + } else { + tokenizationProcessor.encrypt( + decrypted = s.value.asTokenizableRaw(), + tokenKeyType = tokenKeyType, + project = project, + ) + } } } } diff --git a/infra/enrolment-records-store/src/test/java/com/simprints/infra/enrolment/records/store/commcare/CommCareIdentityDataSourceTest.kt b/infra/enrolment-records-store/src/test/java/com/simprints/infra/enrolment/records/store/commcare/CommCareIdentityDataSourceTest.kt index 03df3c2638..4374b1d527 100644 --- a/infra/enrolment-records-store/src/test/java/com/simprints/infra/enrolment/records/store/commcare/CommCareIdentityDataSourceTest.kt +++ b/infra/enrolment-records-store/src/test/java/com/simprints/infra/enrolment/records/store/commcare/CommCareIdentityDataSourceTest.kt @@ -8,6 +8,7 @@ import com.simprints.core.domain.face.FaceSample import com.simprints.core.domain.fingerprint.FingerprintSample import com.simprints.core.domain.fingerprint.IFingerIdentifier.LEFT_INDEX_FINGER import com.simprints.core.domain.fingerprint.IFingerIdentifier.LEFT_THUMB +import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.tools.json.JsonHelper import com.simprints.core.tools.utils.EncodingUtils import com.simprints.infra.config.store.models.Project @@ -259,7 +260,14 @@ class CommCareIdentityDataSourceTest { SUBJECT_ACTIONS_FACE_2, ) val templateFormat = "ROC_1_23" - val query = SubjectQuery(faceSampleFormat = templateFormat) + val query = SubjectQuery( + faceSampleFormat = templateFormat, + attendantId = TokenizableString.Tokenized( + value = "AdySMrjuy7uq0Dcxov3rUFIw66uXTFrKd0BnzSr9MYXl5maWEpyKQT8AUdcPuVHUWpOkO88=", + ), + moduleId = TokenizableString.Tokenized(value = "AWuA3H0WGtHI2uod+ePZ3yiWTt9etQ=="), + subjectId = "b26c91bc-b307-4131-80c3-55090ba5dbf2", + ) val range = 0..expectedFaceIdentities.size val actualIdentities = dataSource.loadFaceIdentities(query, range, project = project) {} diff --git a/infra/enrolment-records-store/src/test/java/com/simprints/infra/enrolment/records/store/usecases/CompareImplicitTokenizedStringsUseCaseTest.kt b/infra/enrolment-records-store/src/test/java/com/simprints/infra/enrolment/records/store/usecases/CompareImplicitTokenizedStringsUseCaseTest.kt index 9308697ebf..838e1bb6a9 100644 --- a/infra/enrolment-records-store/src/test/java/com/simprints/infra/enrolment/records/store/usecases/CompareImplicitTokenizedStringsUseCaseTest.kt +++ b/infra/enrolment-records-store/src/test/java/com/simprints/infra/enrolment/records/store/usecases/CompareImplicitTokenizedStringsUseCaseTest.kt @@ -34,7 +34,7 @@ class CompareImplicitTokenizedStringsUseCaseTest { @Test fun `should return false when s1 is null`() { val s1 = null - val s2 = "s2" + val s2 = TokenizableString.Raw("s2") val result = useCase(s1, s2, tokenKeyType, project) @@ -44,7 +44,7 @@ class CompareImplicitTokenizedStringsUseCaseTest { @Test fun `should return true if strings are equal (both untokenized)`() { val s1 = "s1".asTokenizableRaw() - val s2 = s1.value + val s2 = s1 every { tokenizationProcessor.decrypt(any(), any(), any(), any()) } returns TokenizableString.Tokenized("some value") val result = useCase(s1, s2, tokenKeyType, project) @@ -56,7 +56,7 @@ class CompareImplicitTokenizedStringsUseCaseTest { @Test fun `should return false if strings are not equal (both untokenized)`() { val s1 = "s1".asTokenizableRaw() - val s2 = "s2" + val s2 = "s2".asTokenizableRaw() every { tokenizationProcessor.decrypt(any(), any(), any(), any()) } returns TokenizableString.Tokenized("some value") val result = useCase(s1, s2, tokenKeyType, project) @@ -68,7 +68,7 @@ class CompareImplicitTokenizedStringsUseCaseTest { @Test fun `should call encrypt if only first is tokenized)`() { val s1 = "s1".asTokenizableEncrypted() - val s2 = "s2" + val s2 = "s2".asTokenizableRaw() every { tokenizationProcessor.decrypt(any(), any(), any(), any()) } returns TokenizableString.Tokenized("some value") useCase(s1, s2, tokenKeyType, project) @@ -79,8 +79,8 @@ class CompareImplicitTokenizedStringsUseCaseTest { @Test fun `should call encrypt if only second is tokenized)`() { val s1 = "s1".asTokenizableRaw() - val s2 = "encrypted s1" - every { tokenizationProcessor.decrypt(any(), any(), any(), any()) } returns TokenizableString.Raw(s2) + val s2 = "encrypted s1".asTokenizableEncrypted() + every { tokenizationProcessor.decrypt(any(), any(), any(), any()) } returns TokenizableString.Tokenized("some value") useCase(s1, s2, tokenKeyType, project) @@ -90,7 +90,7 @@ class CompareImplicitTokenizedStringsUseCaseTest { @Test fun `should not call encrypt and return true if both strings are tokenized and equal`() { val s1 = "s1".asTokenizableEncrypted() - val s2 = s1.value + val s2 = "s1".asTokenizableEncrypted() every { tokenizationProcessor.decrypt(any(), any(), any(), any()) } returns TokenizableString.Raw("some value") val result = useCase(s1, s2, tokenKeyType, project) @@ -102,7 +102,7 @@ class CompareImplicitTokenizedStringsUseCaseTest { @Test fun `should not call encrypt and return false if both strings are tokenized but not equal`() { val s1 = "s1".asTokenizableEncrypted() - val s2 = "s2" + val s2 = "s2".asTokenizableEncrypted() every { tokenizationProcessor.decrypt(any(), any(), any(), any()) } returns TokenizableString.Raw("some value") val result = useCase(s1, s2, tokenKeyType, project) @@ -114,7 +114,7 @@ class CompareImplicitTokenizedStringsUseCaseTest { @Test fun `should return false if not equal`() { val s1 = "s1".asTokenizableRaw() - val s2 = "s2" + val s2 = "s2".asTokenizableRaw() val result = useCase(s1, s2, tokenKeyType, project) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializer.kt b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializer.kt new file mode 100644 index 0000000000..e3fc850615 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializer.kt @@ -0,0 +1,61 @@ +package com.simprints.infra.events.event.cosync + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.events.event.domain.models.subject.BiometricReference +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent + +/** + * Deserializer for [EnrolmentRecordCreationEvent] that reads the JSON node and constructs the + * [EnrolmentRecordCreationEvent] object. + * Accounts for past versions of the event where moduleId and attendantId were plain strings. + */ +class CoSyncEnrolmentRecordCreationEventDeserializer : + StdDeserializer( + EnrolmentRecordCreationEvent::class.java, + ) { + override fun deserialize( + p: JsonParser, + ctxt: DeserializationContext, + ): EnrolmentRecordCreationEvent { + val node: JsonNode = p.codec.readTree(p) + val id = node["id"].asText() + val payload = node["payload"] + + val subjectId = payload["subjectId"].asText() + val projectId = payload["projectId"].asText() + + // Try to parse as TokenizableString first, fall back to plain String + val moduleId = try { + ctxt.readTreeAsValue(payload["moduleId"], TokenizableString::class.java) + } catch (_: Exception) { + TokenizableString.Raw(payload["moduleId"].asText()) + } + + // Try to parse as TokenizableString first, fall back to plain String + val attendantId = try { + ctxt.readTreeAsValue(payload["attendantId"], TokenizableString::class.java) + } catch (_: Exception) { + TokenizableString.Raw(payload["attendantId"].asText()) + } + + val biometricReferences = ctxt.readTreeAsValue>( + payload["biometricReferences"], + ctxt.typeFactory.constructCollectionType(List::class.java, BiometricReference::class.java), + ) + + return EnrolmentRecordCreationEvent( + id, + EnrolmentRecordCreationEvent.EnrolmentRecordCreationPayload( + subjectId, + projectId, + moduleId, + attendantId, + biometricReferences, + ), + ) + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializerTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializerTest.kt new file mode 100644 index 0000000000..4309cc4fe9 --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/cosync/CoSyncEnrolmentRecordCreationEventDeserializerTest.kt @@ -0,0 +1,136 @@ +package com.simprints.infra.events.event.cosync + +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.events.event.domain.models.subject.BiometricReference +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals + +class CoSyncEnrolmentRecordCreationEventDeserializerTest { + private val deserializer = CoSyncEnrolmentRecordCreationEventDeserializer() + private val objectMapper = ObjectMapper() + + @Test + fun `deserialize handles old format with plain strings`() { + val json = JSON_TEMPLATE.format(PLAIN_MODULE, PLAIN_ATTENDANT) + val parser = objectMapper.createParser(json) + val context = mockk() + every { + context.readTreeAsValue>( + any(), + any(), + ) + } returns emptyList() + + val result = deserializer.deserialize(parser, context) + + assertEquals(EVENT_ID, result.id) + assertEquals(SUBJECT_ID, result.payload.subjectId) + assertEquals(PROJECT_ID, result.payload.projectId) + assertEquals(TokenizableString.Raw(MODULE_ID), result.payload.moduleId) + assertEquals(TokenizableString.Raw(ATTENDANT_ID), result.payload.attendantId) + assertEquals(emptyList(), result.payload.biometricReferences) + } + + @Test + fun `deserialize handles new format with TokenizableString`() { + val json = JSON_TEMPLATE.format(TOKENIZED_MODULE, RAW_ATTENDANT) + val parser = objectMapper.createParser(json) + val context = mockk() + every { + context.readTreeAsValue(any(), TokenizableString::class.java) + } returns TokenizableString.Tokenized(ENCRYPTED_MODULE) andThen TokenizableString.Raw(UNENCRYPTED_ATTENDANT) + every { + context.readTreeAsValue>( + any(), + any(), + ) + } returns emptyList() + + val result = deserializer.deserialize(parser, context) + + assertEquals(EVENT_ID, result.id) + assertEquals(SUBJECT_ID, result.payload.subjectId) + assertEquals(PROJECT_ID, result.payload.projectId) + assertEquals(TokenizableString.Tokenized(ENCRYPTED_MODULE), result.payload.moduleId) + assertEquals(TokenizableString.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId) + assertEquals(emptyList(), result.payload.biometricReferences) + } + + @Test + fun `deserialize handles new format with TokenizableString but without explicit class`() { + val json = JSON_TEMPLATE.format(TOKENIZED_MODULE_NO_CLASS, RAW_ATTENDANT_NO_CLASS) + val parser = objectMapper.createParser(json) + val context = mockk() + every { + context.readTreeAsValue(any(), TokenizableString::class.java) + } returns TokenizableString.Raw(ENCRYPTED_MODULE) andThen TokenizableString.Raw(UNENCRYPTED_ATTENDANT) + every { + context.readTreeAsValue>( + any(), + any(), + ) + } returns emptyList() + + val result = deserializer.deserialize(parser, context) + + assertEquals(EVENT_ID, result.id) + assertEquals(SUBJECT_ID, result.payload.subjectId) + assertEquals(PROJECT_ID, result.payload.projectId) + assertEquals(TokenizableString.Raw(ENCRYPTED_MODULE), result.payload.moduleId) + assertEquals(TokenizableString.Raw(UNENCRYPTED_ATTENDANT), result.payload.attendantId) + assertEquals(emptyList(), result.payload.biometricReferences) + } + + companion object { + const val EVENT_ID = "event-id" + const val SUBJECT_ID = "subject-1" + const val PROJECT_ID = "project-1" + const val MODULE_ID = "module-1" + const val ATTENDANT_ID = "attendant-1" + const val ENCRYPTED_MODULE = "encrypted-module-1" + const val UNENCRYPTED_ATTENDANT = "unencrypted-attendant-1" + + const val JSON_TEMPLATE = """ + { + "id": "$EVENT_ID", + "payload": { + "subjectId": "$SUBJECT_ID", + "projectId": "$PROJECT_ID", + %s, + %s, + "biometricReferences": [] + } + }""" + + const val PLAIN_MODULE = """ + "moduleId": "$MODULE_ID"""" + const val PLAIN_ATTENDANT = """ + "attendantId": "$ATTENDANT_ID"""" + + const val TOKENIZED_MODULE = """ + "moduleId": { + "className": "TokenizableString.Tokenized", + "value": "$ENCRYPTED_MODULE" + }""" + const val RAW_ATTENDANT = """ + "attendantId": { + "className": "TokenizableString.Raw", + "value": "$UNENCRYPTED_ATTENDANT" + }""" + + const val TOKENIZED_MODULE_NO_CLASS = """ + "moduleId": { + "value": "$ENCRYPTED_MODULE" + }""" + const val RAW_ATTENDANT_NO_CLASS = """ + "attendantId": { + "value": "$UNENCRYPTED_ATTENDANT" + }""" + } +}