diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt index bc5ef22d0c..6a55b6e4a2 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt @@ -45,6 +45,7 @@ import com.simprints.infra.uibase.view.fadeOut import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -85,6 +86,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex private var checkAnimator: ViewPropertyAnimator? = null private var isAnimatingCompletion: Boolean = false private var pendingFinishAction: (() -> Unit)? = null + private var ocrPreProcessingJob: Job? = null @Inject lateinit var viewModelFactory: ExternalCredentialScanOcrViewModel.Factory @@ -106,7 +108,6 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex super.onViewCreated(view, savedInstanceState) applySystemBarInsets(view) Simber.i("ExternalCredentialScanOcrFragment started", tag = MULTI_FACTOR_ID) - initObservers() } override fun onResume() { @@ -132,11 +133,11 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex } } - override fun onDestroy() { + override fun onDestroyView() { stopOcr() stopCamera() clearAnimations() - super.onDestroy() + super.onDestroyView() } private fun clearAnimations() { @@ -147,7 +148,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex } private fun initializeFragment() { - renderInitialState() + initObservers() initCamera(onComplete = { if (viewModel.isOcrActive) { startOcr() @@ -156,7 +157,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex } private fun initObservers() { - viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> + viewModel.scanOcrStateLiveData.observe(viewLifecycleOwner) { state -> when (state) { is ScanOcrState.ScanningInProgress -> { renderProgress(state) @@ -299,48 +300,46 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex // Running OCR as often as we can while camera feedback is displayed to the user viewModel.ocrOnFrameStarted() if (viewModel.ocrConfig.useHighRes) { - captureHighResImageForOcr() videoFrame.close() + captureHighResImageForOcr { highResImage -> + preProcessImageAndRunOcr(highResImage) + } } else { - captureFrameFromVideoStreamForOcr(videoFrame) + preProcessImageAndRunOcr(videoFrame) } } } - private fun captureFrameFromVideoStreamForOcr(imageProxy: ImageProxy) { - lifecycleScope.launch(bgDispatcher) { + private fun preProcessImageAndRunOcr(imageProxy: ImageProxy) { + ocrPreProcessingJob?.cancel() + ocrPreProcessingJob = lifecycleScope.launch(bgDispatcher) { try { val (bitmap, imageInfo) = imageProxy.toBitmap() to imageProxy.imageInfo - val cropConfig: OcrCropConfig = buildOcrCropConfigUseCase( - rotationDegrees = imageInfo.rotationDegrees, - cameraPreview = binding.preview, - documentScannerArea = binding.documentScannerArea, - ) - viewModel.runOcrOnFrame(frame = bitmap, cropConfig) + if (ocrPreProcessingJob?.isActive == true) { + val cropConfig: OcrCropConfig = buildOcrCropConfigUseCase( + rotationDegrees = imageInfo.rotationDegrees, + cameraPreview = binding.preview, + documentScannerArea = binding.documentScannerArea, + ) + viewModel.runOcrOnFrame(frame = bitmap, cropConfig) + } else { + Simber.i( + "Unable to run OCR preprocessing, coroutine context is cancelled", + tag = MULTI_FACTOR_ID, + ) + } } finally { imageProxy.close() } } } - private fun captureHighResImageForOcr() { + private fun captureHighResImageForOcr(onImageCaptured: (ImageProxy) -> Unit) { imageCapture.takePicture( cameraExecutor, object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(imageProxy: ImageProxy) { - lifecycleScope.launch(bgDispatcher) { - try { - val (bitmap, imageInfo) = imageProxy.toBitmap() to imageProxy.imageInfo - val cropConfig: OcrCropConfig = buildOcrCropConfigUseCase( - rotationDegrees = imageInfo.rotationDegrees, - cameraPreview = binding.preview, - documentScannerArea = binding.documentScannerArea, - ) - viewModel.runOcrOnFrame(frame = bitmap, cropConfig) - } finally { - imageProxy.close() - } - } + onImageCaptured(imageProxy) } override fun onError(e: ImageCaptureException) { @@ -357,6 +356,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex } private fun stopOcr() { + ocrPreProcessingJob?.cancel() if (::imageAnalysis.isInitialized) { imageAnalysis.clearAnalyzer() } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt index b557c4895e..ff0873f996 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt @@ -24,7 +24,6 @@ import com.simprints.feature.externalcredential.screens.scanocr.usecase.Normaliz import com.simprints.feature.externalcredential.screens.scanocr.usecase.ZoomOntoCredentialUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.models.experimental import com.simprints.infra.config.store.tokenization.TokenizationProcessor @@ -64,13 +63,13 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( private set val isOcrActive: Boolean get() = detectedBlocks.isNotEmpty() - private var state: ScanOcrState = ScanOcrState.EMPTY + private var ocrState: ScanOcrState = ScanOcrState.EMPTY set(value) { field = value - _stateLiveData.postValue(value) + _scanOcrStateLiveData.postValue(value) } - private val _stateLiveData = MutableLiveData() - val stateLiveData: LiveData = _stateLiveData + private val _scanOcrStateLiveData = MutableLiveData(ocrState) + val scanOcrStateLiveData: LiveData = _scanOcrStateLiveData val finishOcrEvent: LiveData> get() = _finishOcrEvent private val _finishOcrEvent = MutableLiveData>() @@ -91,7 +90,7 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } private fun updateState(state: (ScanOcrState) -> ScanOcrState) { - this.state = state(this.state) + this.ocrState = state(this.ocrState) } fun getDocumentTypeRes(): Int = when (ocrDocumentType) { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt index 13996eebb8..53715ebb6d 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt @@ -87,15 +87,23 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext } } + override fun onPause() { + if (isCameraInitialized) { + cameraHelper.stopCamera() + isCameraInitialized = false + } + super.onPause() + } + override fun onResume() { super.onResume() val cameraPermissionStatus = requireActivity().getCurrentPermissionStatus(CAMERA) viewModel.updateCameraPermissionStatus(cameraPermissionStatus) } - override fun onDestroy() { + override fun onDestroyView() { dismissDialog() - super.onDestroy() + super.onDestroyView() } private fun dismissDialog() { diff --git a/feature/external-credential/src/main/res/layout/fragment_external_credential_scan_qr.xml b/feature/external-credential/src/main/res/layout/fragment_external_credential_scan_qr.xml index a6ec010214..0d29c28226 100644 --- a/feature/external-credential/src/main/res/layout/fragment_external_credential_scan_qr.xml +++ b/feature/external-credential/src/main/res/layout/fragment_external_credential_scan_qr.xml @@ -65,6 +65,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="8dp" + android:ellipsize="end" + android:maxLines="3" tools:text="QR_CODE" /> diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt index 74624f2de2..cec68f27a9 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test -import kotlin.concurrent.timer internal class ExternalCredentialScanOcrViewModelTest { @get:Rule @@ -104,7 +103,7 @@ internal class ExternalCredentialScanOcrViewModelTest { @Test fun `ocrStarted updates state to ScanningInProgress`() { - val observer = viewModel.stateLiveData.test() + val observer = viewModel.scanOcrStateLiveData.test() viewModel.ocrStarted() val state = observer.value() as ScanOcrState.ScanningInProgress @@ -122,7 +121,7 @@ internal class ExternalCredentialScanOcrViewModelTest { coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap coEvery { getCredentialCoordinatesUseCase(mockCroppedBitmap, documentType) } returns mockDetectedBlock - val observer = viewModel.stateLiveData.test() + val observer = viewModel.scanOcrStateLiveData.test() viewModel.ocrOnFrameStarted() viewModel.runOcrOnFrame(bitmap, cropConfig) @@ -158,7 +157,7 @@ internal class ExternalCredentialScanOcrViewModelTest { coEvery { credentialImageRepository.saveCredentialScan(mockBitmap, any()) } returns zoomedImagePath val finishObserver = viewModel.finishOcrEvent.test() - val stateObserver = viewModel.stateLiveData.test() + val stateObserver = viewModel.scanOcrStateLiveData.test() viewModel.ocrStarted() // Initialises capture timing viewModel.processOcrResultsAndFinish() diff --git a/feature/matcher/src/main/java/com/simprints/matcher/screen/MatchViewModel.kt b/feature/matcher/src/main/java/com/simprints/matcher/screen/MatchViewModel.kt index 2fc45b1ef9..fa86d5a068 100644 --- a/feature/matcher/src/main/java/com/simprints/matcher/screen/MatchViewModel.kt +++ b/feature/matcher/src/main/java/com/simprints/matcher/screen/MatchViewModel.kt @@ -33,6 +33,8 @@ internal class MatchViewModel @Inject constructor( private val configManager: ConfigManager, private val timeHelper: TimeHelper, ) : ViewModel() { + var isMatcherRunning = false + private set var isInitialized = false private set private var candidatesLoaded = 0 @@ -48,6 +50,8 @@ internal class MatchViewModel @Inject constructor( private val _matchResponse = MutableLiveData>() fun setupMatch(params: MatchParams) = viewModelScope.launch { + if (isMatcherRunning) return@launch + isMatcherRunning = true isInitialized = true val startTime = timeHelper.now() diff --git a/feature/matcher/src/test/java/com/simprints/matcher/screen/MatchViewModelTest.kt b/feature/matcher/src/test/java/com/simprints/matcher/screen/MatchViewModelTest.kt index 7e4ffd3f99..67e2573625 100644 --- a/feature/matcher/src/test/java/com/simprints/matcher/screen/MatchViewModelTest.kt +++ b/feature/matcher/src/test/java/com/simprints/matcher/screen/MatchViewModelTest.kt @@ -355,6 +355,43 @@ internal class MatchViewModelTest { ) } + @Test + fun `setupMatch does not continue when isMatcherRunning is true`() = runTest { + // Seting up a matcher runs in background + coEvery { faceMatcherUseCase.invoke(any(), any()) } returns flow { + emit(MatcherUseCase.MatcherState.LoadingStarted(1)) + emit(MatcherUseCase.MatcherState.CandidateLoaded) + emit( + MatcherUseCase.MatcherState.Success( + matchResultItems = listOf(FaceMatchResult.Item("1", 90f)), + totalCandidates = 1, + matcherName = MATCHER_NAME, + matchBatches = emptyList(), + ), + ) + } + coJustRun { saveMatchEvent.invoke(any(), any(), any(), any(), any(), any(), any()) } + + val states = viewModel.matchState.test() + val matchParams = MatchParams( + probeReferenceId = "referenceId", + probeFaceSamples = listOf(getFaceSample()), + faceSDK = FaceConfiguration.BioSdk.RANK_ONE, + flowType = FlowType.ENROL, + queryForCandidates = mockk {}, + biometricDataSource = BiometricDataSource.Simprints, + ) + + viewModel.setupMatch(matchParams) + assertThat(viewModel.isMatcherRunning).isTrue() + viewModel.setupMatch(matchParams) + advanceUntilIdle() + + coVerify(exactly = 1) { faceMatcherUseCase.invoke(any(), any()) } + // Checking that no new states were emitted. History = (NotStarted, LoadingCandidates LoadingCandidates, Finished) + assertThat(states.valueHistory()).hasSize(4) + } + private fun getFaceSample(): MatchParams.FaceSample = MatchParams.FaceSample(UUID.randomUUID().toString(), Random.nextBytes(20)) private fun getFingerprintSample(): MatchParams.FingerprintSample = MatchParams.FingerprintSample( diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt index 17f3964b03..c3afc6e5cc 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt @@ -261,7 +261,7 @@ internal class EventUpSyncTask @Inject constructor( .also { listOfEvents -> emit(listOfEvents.size) } } catch (ex: Exception) { if (ex is JsonParseException || ex is JsonMappingException) { - Simber.i("Failed to un-marshal events", ex, tag = SYNC) + Simber.e("Failed to un-marshal events", ex, tag = SYNC) } else { throw ex } diff --git a/infra/events/schemas/com.simprints.infra.events.event.local.EventRoomDatabase/17.json b/infra/events/schemas/com.simprints.infra.events.event.local.EventRoomDatabase/17.json new file mode 100644 index 0000000000..2313cbd326 --- /dev/null +++ b/infra/events/schemas/com.simprints.infra.events.event.local.EventRoomDatabase/17.json @@ -0,0 +1,138 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "d5dd0e6fc8f6d48c5f58e7b191bc8d3d", + "entities": [ + { + "tableName": "DbEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `projectId` TEXT, `scopeId` TEXT, `eventJson` TEXT NOT NULL, `createdAt_unixMs` INTEGER NOT NULL, `createdAt_isTrustworthy` INTEGER NOT NULL, `createdAt_msSinceBoot` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT" + }, + { + "fieldPath": "scopeId", + "columnName": "scopeId", + "affinity": "TEXT" + }, + { + "fieldPath": "eventJson", + "columnName": "eventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt.unixMs", + "columnName": "createdAt_unixMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt.isTrustworthy", + "columnName": "createdAt_isTrustworthy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt.msSinceBoot", + "columnName": "createdAt_msSinceBoot", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "DbEventScope", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `projectId` TEXT NOT NULL, `type` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `start_unixMs` INTEGER NOT NULL, `start_isTrustworthy` INTEGER NOT NULL, `start_msSinceBoot` INTEGER, `end_unixMs` INTEGER, `end_isTrustworthy` INTEGER, `end_msSinceBoot` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt.unixMs", + "columnName": "start_unixMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt.isTrustworthy", + "columnName": "start_isTrustworthy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt.msSinceBoot", + "columnName": "start_msSinceBoot", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt.unixMs", + "columnName": "end_unixMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt.isTrustworthy", + "columnName": "end_isTrustworthy", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt.msSinceBoot", + "columnName": "end_msSinceBoot", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, 'd5dd0e6fc8f6d48c5f58e7b191bc8d3d')" + ] + } +} \ No newline at end of file diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDatabase.kt b/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDatabase.kt index 323dc422a4..fe71bc5f01 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDatabase.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDatabase.kt @@ -13,6 +13,7 @@ import com.simprints.infra.events.event.local.migrations.EventMigration12to13 import com.simprints.infra.events.event.local.migrations.EventMigration13to14 import com.simprints.infra.events.event.local.migrations.EventMigration14to15 import com.simprints.infra.events.event.local.migrations.EventMigration15to16 +import com.simprints.infra.events.event.local.migrations.EventMigration16to17 import com.simprints.infra.events.event.local.migrations.EventMigration1to2 import com.simprints.infra.events.event.local.migrations.EventMigration2to3 import com.simprints.infra.events.event.local.migrations.EventMigration3to4 @@ -31,7 +32,7 @@ import net.zetetic.database.sqlcipher.SupportOpenHelperFactory DbEvent::class, DbEventScope::class, ], - version = 16, + version = 17, exportSchema = true, ) @TypeConverters(Converters::class) @@ -64,6 +65,7 @@ internal abstract class EventRoomDatabase : RoomDatabase() { .addMigrations(EventMigration13to14()) .addMigrations(EventMigration14to15()) .addMigrations(EventMigration15to16()) + .addMigrations(EventMigration16to17()) if (BuildConfig.DB_ENCRYPTION) { builder.openHelperFactory(factory) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17.kt b/infra/events/src/main/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17.kt new file mode 100644 index 0000000000..545ff4fc37 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17.kt @@ -0,0 +1,67 @@ +package com.simprints.infra.events.event.local.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.simprints.core.tools.extentions.getStringWithColumnName +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MIGRATION +import com.simprints.infra.logging.Simber +import org.json.JSONArray +import org.json.JSONObject + +/** + * Starting from 2025.4.0, EnrolmentEventV4 requires the `externalCredentialIds` field. + * This migration adds an empty `externalCredentialIds` array field to the EnrolmentEventV4 event + * + * This migration adds: + * "externalCredentialIds": [], + * to the payload object. + */ +internal class EventMigration16to17 : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + Simber.i("Migrating room db from schema 16 to schema 17.", tag = MIGRATION) + migrateEnrolmentEventJson(db) + Simber.i("Migration from schema 16 to schema 17 done.", tag = MIGRATION) + } + + private fun migrateEnrolmentEventJson(database: SupportSQLiteDatabase) { + val eventsQuery = database.query( + "SELECT * FROM $DB_EVENT_ENTITY WHERE type = ?", + arrayOf(EVENT_TYPE_ENROLMENT_V4), + ) + eventsQuery.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getStringWithColumnName("id") ?: continue + val jsonData = cursor.getStringWithColumnName(DB_EVENT_JSON_FIELD) ?: continue + + try { + val jsonObject = JSONObject(jsonData) + val payload = jsonObject.optJSONObject(PAYLOAD_JSON_FIELD) ?: continue + + // Only adding if 'externalCredentialIds' field doesn't exist + if (!payload.has(EXTERNAL_CREDENTIAL_IDS_JSON_FIELD)) { + payload.put(EXTERNAL_CREDENTIAL_IDS_JSON_FIELD, JSONArray()) + val migratedJson = jsonObject.toString() + database.execSQL( + "UPDATE $DB_EVENT_ENTITY SET $DB_EVENT_JSON_FIELD = ? WHERE id = ?", + arrayOf(migratedJson, id), + ) + } + } catch (e: Exception) { + Simber.e( + "Failed to migrate room db from schema 16 to schema 17.", + e, + tag = MIGRATION, + ) + } + } + } + } + + companion object { + private const val DB_EVENT_ENTITY = "DbEvent" + private const val DB_EVENT_JSON_FIELD = "eventJson" + private const val EVENT_TYPE_ENROLMENT_V4 = "ENROLMENT_V4" + private const val EXTERNAL_CREDENTIAL_IDS_JSON_FIELD = "externalCredentialIds" + private const val PAYLOAD_JSON_FIELD = "payload" + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17Test.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17Test.kt new file mode 100644 index 0000000000..8038370fb0 --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigration16to17Test.kt @@ -0,0 +1,255 @@ +package com.simprints.infra.events.event.local.migrations + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import com.simprints.core.tools.extentions.getStringWithColumnName +import com.simprints.core.tools.utils.randomUUID +import com.simprints.infra.events.event.local.EventRoomDatabase +import io.mockk.spyk +import io.mockk.verify +import org.json.JSONObject +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class EventMigration16to17Test { + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + EventRoomDatabase::class.java, + ) + + @Test + @Throws(IOException::class) + fun `validate end to end migration is successful`() { + val eventId = randomUUID() + + setupV16DbWithEvent(eventId) + + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, EventMigration16to17()) + + val eventJson = MigrationTestingTools + .retrieveCursorWithEventById(db, eventId) + .getStringWithColumnName("eventJson")!! + + val jsonObject = JSONObject(eventJson) + val payload = jsonObject.getJSONObject("payload") + assertThat(eventJson).contains("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\":[]") + assertThat(payload.has(EXTERNAL_CREDENTIAL_IDS_JSON_KEY)).isTrue() + assertThat(payload.getJSONArray(EXTERNAL_CREDENTIAL_IDS_JSON_KEY).length()).isEqualTo(0) + + db.close() + } + + @Test + fun `validate migration is called`() { + val migrationSpy = spyk(EventMigration16to17()) + + setupV16DbWithEvent(randomUUID()) + helper.runMigrationsAndValidate(TEST_DB, 17, true, migrationSpy) + + verify(exactly = 1) { migrationSpy.migrate(any()) } + } + + @Test + fun `validate all ENROLMENT_V4 events are migrated`() { + val eventId1 = randomUUID() + val eventId2 = randomUUID() + + setupV16DbWithEvent(eventId1, eventId2) + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, EventMigration16to17()) + + MigrationTestingTools.retrieveCursorWithEventById(db, eventId1).use { cursor -> + val eventJson = cursor.getStringWithColumnName("eventJson") + assertThat(eventJson).contains("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\":[]") + } + + MigrationTestingTools.retrieveCursorWithEventById(db, eventId2).use { cursor -> + val eventJson = cursor.getStringWithColumnName("eventJson") + assertThat(eventJson).contains("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\":[]") + } + + db.close() + } + + @Test + fun `validate migration query is called`() { + val migrationSpy = spyk(EventMigration16to17()) + + val db = spyk(setupV16DbWithEvent(randomUUID(), close = false)) + migrationSpy.migrate(db) + + verify(atLeast = 1) { db.query(any()) } + db.close() + } + + @Test + fun `validate migration does not add field if it already exists`() { + val eventId = randomUUID() + + setupV16DbWithEventWithExternalCredentialIds(eventId) + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, EventMigration16to17()) + + val eventJson = MigrationTestingTools + .retrieveCursorWithEventById(db, eventId) + .getStringWithColumnName("eventJson")!! + + // Verify the field appears only once + val firstIndex = eventJson.indexOf("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\"") + val lastIndex = eventJson.lastIndexOf("\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\"") + assertThat(firstIndex).isEqualTo(lastIndex) + assertThat(eventJson).contains(EXTERNAL_CREDENTIAL_IDS_JSON_FIELD) + + db.close() + } + + @Test + fun `validate non-ENROLMENT_V4 events are not modified`() { + val eventId = randomUUID() + + setupV16DbWithNonEnrolmentEvent(eventId) + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, EventMigration16to17()) + + val eventJson = MigrationTestingTools + .retrieveCursorWithEventById(db, eventId) + .getStringWithColumnName("eventJson")!! + + assertThat(eventJson).doesNotContain(EXTERNAL_CREDENTIAL_IDS_JSON_KEY) + db.close() + } + + private fun createEnrolmentEvent( + id: String, + addCredentialIds: Boolean, + ) = ContentValues().apply { + val externalCredentialIds = if (addCredentialIds) { + EXTERNAL_CREDENTIAL_IDS_JSON_FIELD + } else { + "" + } + put("id", id) + put("type", "ENROLMENT_V4") + put("createdAt_unixMs", 123) + put("createdAt_isTrustworthy", 0) + put("projectId", "9WNCAbWVNrxttDe5hgwb") + put("scopeId", "2bdc1145") + val unversionedEnrolmentEvent = + """ + { + "id":"$id", + "payload":{ + "createdAt":{ + "ms":1762805893067, + "isTrustworthy":true, + "msSinceBoot":35002538 + }, + "eventVersion":4, + "subjectId":"74639420-8e77-4a40-a452-280f295f147f", + "projectId":"FW1jU2kjy1cV9RWXdosN", + "moduleId":{ + "className":"TokenizableString.Tokenized", + "value":"AV50RNsaMs9jpoHwcXZqir1uB3St0vsexOpixA==" + }, + "attendantId":{ + "className":"TokenizableString.Tokenized", + "value":"AQYk7uBNIkhgOVGR3f/0HTjX/LRk0fKi+g==" + }, + "biometricReferenceIds":[ + "b12815ff-a4bc-4d7f-ae88-608640c7138d" + ], + $externalCredentialIds + "type":"ENROLMENT_V4" + }, + "type":"ENROLMENT_V4", + "scopeId":"c33023a1-d335-4310-b088-81575daafea3", + "projectId":"FW1jU2kjy1cV9RWXdosN" + } + """.trimIndent() + put("eventJson", unversionedEnrolmentEvent) + } + + private fun createNonEnrolmentEvent(id: String) = ContentValues().apply { + put("id", id) + put("type", "INTENT_PARSING") + put("createdAt_unixMs", 123) + put("createdAt_isTrustworthy", 0) + put("projectId", "9WNCAbWVNrxttDe5hgwb") + put("scopeId", "2bdc1145") + put( + "eventJson", + """ + { + "id":"$id", + "projectId":"9WNCAbWVNrxttDe5hgwb", + "sessionId":"2bdc1145-cbec-4e6a-ac8a-61c1e5b53bb4", + "payload":{ + "createdAt":{ + "ms":1706534485916, + "isTrustworthy":false, + "msSinceBoot":null + }, + "eventVersion":2, + "integration":"STANDARD", + "type":"INTENT_PARSING", + "endedAt":{ + "ms":1706534528165, + "isTrustworthy":false, + "msSinceBoot":null + } + }, + "type":"INTENT_PARSING" + } + """.trimIndent(), + ) + } + + private fun setupV16DbWithEvent( + vararg eventId: String, + close: Boolean = true, + ): SupportSQLiteDatabase = helper.createDatabase(TEST_DB, 16).apply { + eventId.forEach { id -> + val event = createEnrolmentEvent(id, addCredentialIds = false) + this.insert("DbEvent", SQLiteDatabase.CONFLICT_NONE, event) + } + if (close) { + close() + } + } + + private fun setupV16DbWithEventWithExternalCredentialIds( + eventId: String, + close: Boolean = true, + ): SupportSQLiteDatabase = helper.createDatabase(TEST_DB, 16).apply { + val event = createEnrolmentEvent(eventId, addCredentialIds = true) + this.insert("DbEvent", SQLiteDatabase.CONFLICT_NONE, event) + if (close) { + close() + } + } + + private fun setupV16DbWithNonEnrolmentEvent( + eventId: String, + close: Boolean = true, + ): SupportSQLiteDatabase = helper.createDatabase(TEST_DB, 16).apply { + val event = createNonEnrolmentEvent(eventId) + this.insert("DbEvent", SQLiteDatabase.CONFLICT_NONE, event) + if (close) { + close() + } + } + + companion object { + private const val TEST_DB = "test" + private const val EXTERNAL_CREDENTIAL_IDS_JSON_KEY = "externalCredentialIds" + private const val EXTERNAL_CREDENTIAL_IDS_JSON_FIELD = + "\"$EXTERNAL_CREDENTIAL_IDS_JSON_KEY\": [\"74639420-8e77-4a40-a452-280f295f147f\"]\"," + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt index 13f3e1e9f1..feafb7444b 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt @@ -47,7 +47,7 @@ class EventMigrationTest { } close() } - val db = helper.runMigrationsAndValidate(TEST_DB, 16, true, *ALL_MIGRATIONS) + val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, *ALL_MIGRATIONS) db.query("SELECT * FROM $TABLE_NAME").use { cursor -> while (cursor.moveToNext()) { val eventJson = cursor.getStringWithColumnName("eventJson")!! @@ -113,6 +113,7 @@ class EventMigrationTest { EventMigration13to14(), EventMigration14to15(), EventMigration15to16(), + EventMigration16to17(), ) val tokenizeSerializationModule = SimpleModule().apply { addSerializer(TokenizableString::class.java, TokenizationClassNameSerializer()) diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraHelper.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraHelper.kt index aeedfb8e94..7192207ad1 100644 --- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraHelper.kt +++ b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraHelper.kt @@ -28,6 +28,7 @@ class CameraHelper @AssistedInject constructor( private val cameraFocusManagerFactory: CameraFocusManager.Factory, ) { private val cameraFocusManager by lazy { cameraFocusManagerFactory.create(crashReportTag) } + private var cameraProvider: ProcessCameraProvider? = null @AssistedFactory interface Factory { @@ -43,9 +44,13 @@ class CameraHelper @AssistedInject constructor( val providerFuture = ProcessCameraProvider.getInstance(context) providerFuture.addListener( { - val cameraProvider = providerFuture.get() + val provider = providerFuture.get().also { provider -> + cameraProvider = provider + provider.unbindAll() + } + // Check if the back camera is available - if (cameraProvider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA).not()) { + if (provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA).not()) { initializationErrorListener.onCameraError() return@addListener } @@ -54,7 +59,7 @@ class CameraHelper @AssistedInject constructor( val preview = buildPreview(cameraPreview) try { - cameraProvider + provider .bindToLifecycle( lifecycleOwner, cameraSelector, @@ -75,6 +80,11 @@ class CameraHelper @AssistedInject constructor( ) } + fun stopCamera() { + cameraProvider?.unbindAll() + cameraProvider = null + } + private fun buildAnalyser(qrAnalyser: QrCodeAnalyzer) = ImageAnalysis .Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)