diff --git a/fingerprint/capture/src/main/java/com/simprints/fingerprint/capture/screen/FingerprintCaptureViewModel.kt b/fingerprint/capture/src/main/java/com/simprints/fingerprint/capture/screen/FingerprintCaptureViewModel.kt index b89c832f5d..a40429cced 100644 --- a/fingerprint/capture/src/main/java/com/simprints/fingerprint/capture/screen/FingerprintCaptureViewModel.kt +++ b/fingerprint/capture/src/main/java/com/simprints/fingerprint/capture/screen/FingerprintCaptureViewModel.kt @@ -176,13 +176,14 @@ internal class FingerprintCaptureViewModel @Inject constructor( private suspend fun initBioSdk() { try { - bioSdkWrapper= resolveBioSdkWrapperUseCase() + bioSdkWrapper = resolveBioSdkWrapperUseCase() bioSdkWrapper.initialize() } catch (e: BioSdkException.BioSdkInitializationException) { Simber.e(e) _invalidLicense.send() } } + private fun launchReconnect() { if (!state.isShowingConnectionScreen) { updateState { @@ -265,7 +266,7 @@ internal class FingerprintCaptureViewModel @Inject constructor( bioSdkWrapper.scanningTimeoutMs + if (isImageTransferRequired()) bioSdkWrapper.imageTransferTimeoutMs else 0 - private fun isImageTransferRequired(): Boolean = + private fun isImageTransferRequired(): Boolean = bioSdkConfiguration.vero2?.imageSavingStrategy?.isImageTransferRequired() ?: false && scannerManager.scanner.isImageTransferSupported() @@ -504,6 +505,7 @@ internal class FingerprintCaptureViewModel @Inject constructor( Simber.e(e) handleNoFingerDetected() } + else -> { updateCaptureState { toNotCollected() } Simber.e(e) @@ -614,7 +616,12 @@ internal class FingerprintCaptureViewModel @Inject constructor( private suspend fun saveImageIfExists(id: CaptureId, collectedFinger: CaptureState.Collected) { val captureEventId = captureEventIds[id] - val imageRef = saveImage(bioSdkConfiguration.vero2!!, captureEventId, collectedFinger) + val imageRef = saveImage( + vero2Configuration = bioSdkConfiguration.vero2!!, + finger = id.finger, + captureEventId = captureEventId, + collectedFinger = collectedFinger, + ) imageRefs[id] = imageRef } diff --git a/fingerprint/capture/src/main/java/com/simprints/fingerprint/capture/usecase/SaveImageUseCase.kt b/fingerprint/capture/src/main/java/com/simprints/fingerprint/capture/usecase/SaveImageUseCase.kt index 9843724593..ec4b2b0f0e 100644 --- a/fingerprint/capture/src/main/java/com/simprints/fingerprint/capture/usecase/SaveImageUseCase.kt +++ b/fingerprint/capture/src/main/java/com/simprints/fingerprint/capture/usecase/SaveImageUseCase.kt @@ -1,7 +1,9 @@ package com.simprints.fingerprint.capture.usecase +import com.simprints.core.domain.fingerprint.IFingerIdentifier import com.simprints.fingerprint.capture.exceptions.FingerprintUnexpectedException import com.simprints.fingerprint.capture.extensions.deduceFileExtension +import com.simprints.fingerprint.capture.extensions.toInt import com.simprints.fingerprint.capture.state.CaptureState import com.simprints.infra.config.store.models.Vero2Configuration import com.simprints.infra.events.SessionEventRepository @@ -18,13 +20,16 @@ internal class SaveImageUseCase @Inject constructor( suspend operator fun invoke( vero2Configuration: Vero2Configuration, + finger: IFingerIdentifier, captureEventId: String?, collectedFinger: CaptureState.Collected, ) = if (collectedFinger.scanResult.image != null && captureEventId != null) { saveImage( - collectedFinger.scanResult.image, - captureEventId, - vero2Configuration.imageSavingStrategy.deduceFileExtension() + imageBytes = collectedFinger.scanResult.image, + captureEventId = captureEventId, + fileExtension = vero2Configuration.imageSavingStrategy.deduceFileExtension(), + finger = finger, + dpi = vero2Configuration.captureStrategy.toInt(), ) } else if (collectedFinger.scanResult.image != null && captureEventId == null) { Simber.e(FingerprintUnexpectedException("Could not save fingerprint image because of null capture ID")) @@ -35,12 +40,22 @@ internal class SaveImageUseCase @Inject constructor( imageBytes: ByteArray, captureEventId: String, fileExtension: String, + finger: IFingerIdentifier, + dpi: Int, ): SecuredImageRef? = determinePath(captureEventId, fileExtension)?.let { path -> Simber.d("Saving fingerprint image ${path}") val currentSession = coreEventRepository.getCurrentSessionScope() val projectId = currentSession.projectId - val securedImageRef = coreImageRepository.storeImageSecurely(imageBytes, projectId, Path(path.parts)) + val securedImageRef = coreImageRepository.storeImageSecurely( + imageBytes = imageBytes, + projectId = projectId, + relativePath = Path(path.parts), + metadata = mapOf( + META_KEY_FINGER_ID to finger.name, + META_KEY_DPI to dpi.toString(), + ), + ) if (securedImageRef != null) { SecuredImageRef(Path(securedImageRef.relativePath.parts)) @@ -70,5 +85,8 @@ internal class SaveImageUseCase @Inject constructor( const val SESSIONS_PATH = "sessions" const val FINGERPRINTS_PATH = "fingerprints" + + private const val META_KEY_DPI = "dpi" + private const val META_KEY_FINGER_ID = "finger" } } diff --git a/fingerprint/capture/src/test/java/com/simprints/fingerprint/capture/screen/FingerprintCaptureViewModelTest.kt b/fingerprint/capture/src/test/java/com/simprints/fingerprint/capture/screen/FingerprintCaptureViewModelTest.kt index ea54548bcf..c432a0ff4e 100644 --- a/fingerprint/capture/src/test/java/com/simprints/fingerprint/capture/screen/FingerprintCaptureViewModelTest.kt +++ b/fingerprint/capture/src/test/java/com/simprints/fingerprint/capture/screen/FingerprintCaptureViewModelTest.kt @@ -438,7 +438,7 @@ class FingerprintCaptureViewModelTest { ) coVerify(exactly = 12) { addCaptureEventsUseCase.invoke(any(), any(), any(), any()) } vm.handleConfirmFingerprintsAndContinue() - coVerify(exactly = 4) { saveImageUseCase.invoke(any(), any(), any()) } + coVerify(exactly = 4) { saveImageUseCase.invoke(any(), any(), any(), any()) } vm.finishWithFingerprints.assertEventReceivedWithContentAssertions { actualFingerprints -> assertThat(actualFingerprints?.results).hasSize(FOUR_FINGERS_IDS.size) @@ -489,7 +489,7 @@ class FingerprintCaptureViewModelTest { coVerify(exactly = 2) { addCaptureEventsUseCase.invoke(any(), any(), any(), any()) } vm.handleConfirmFingerprintsAndContinue() - coVerify(exactly = 2) { saveImageUseCase.invoke(any(), any(), any()) } + coVerify(exactly = 2) { saveImageUseCase.invoke(any(), any(), any(), any()) } vm.finishWithFingerprints.assertEventReceivedWithContentAssertions { actualFingerprints -> assertThat(actualFingerprints?.results).hasSize(TWO_FINGERS_IDS.size) @@ -538,7 +538,7 @@ class FingerprintCaptureViewModelTest { ) coVerify(exactly = 2) { addCaptureEventsUseCase.invoke(any(), any(), any(), any()) } vm.handleConfirmFingerprintsAndContinue() - coVerify(exactly = 0) { saveImageUseCase.invoke(any(), any(), any()) } + coVerify(exactly = 0) { saveImageUseCase.invoke(any(), any(), any(), any()) } vm.finishWithFingerprints.assertEventReceivedWithContentAssertions { actualFingerprints -> assertThat(actualFingerprints?.results).hasSize(TWO_FINGERS_IDS.size) @@ -736,7 +736,7 @@ class FingerprintCaptureViewModelTest { coVerify(exactly = 14) { addCaptureEventsUseCase.invoke(any(), any(), any(), any()) } vm.handleConfirmFingerprintsAndContinue() - coVerify(exactly = 3) { saveImageUseCase.invoke(any(), any(), any()) } + coVerify(exactly = 3) { saveImageUseCase.invoke(any(), any(), any(), any()) } vm.finishWithFingerprints.assertEventReceivedWithContentAssertions { actualFingerprints -> assertThat(actualFingerprints?.results).hasSize(3) @@ -844,7 +844,7 @@ class FingerprintCaptureViewModelTest { coVerify(exactly = 14) { addCaptureEventsUseCase.invoke(any(), any(), any(), any()) } // If eager, expect that images were saved before confirm was pressed, including bad scans - coVerify(exactly = 8) { saveImageUseCase.invoke(any(), any(), any()) } + coVerify(exactly = 8) { saveImageUseCase.invoke(any(), any(), any(), any()) } vm.handleConfirmFingerprintsAndContinue() @@ -1234,7 +1234,7 @@ class FingerprintCaptureViewModelTest { private fun withImageTransfer(isEager: Boolean = false) { every { vero2Configuration.imageSavingStrategy } returns if (isEager) ImageSavingStrategy.EAGER else ImageSavingStrategy.ONLY_GOOD_SCAN - coEvery { saveImageUseCase.invoke(any(), any(), any()) } returns mockk { + coEvery { saveImageUseCase.invoke(any(), any(), any(), any()) } returns mockk { every { relativePath } returns Path(emptyArray()) } } diff --git a/fingerprint/capture/src/test/java/com/simprints/fingerprint/capture/usecase/SaveImageUseCaseTest.kt b/fingerprint/capture/src/test/java/com/simprints/fingerprint/capture/usecase/SaveImageUseCaseTest.kt index 06455e664f..9ce4139714 100644 --- a/fingerprint/capture/src/test/java/com/simprints/fingerprint/capture/usecase/SaveImageUseCaseTest.kt +++ b/fingerprint/capture/src/test/java/com/simprints/fingerprint/capture/usecase/SaveImageUseCaseTest.kt @@ -1,6 +1,7 @@ package com.simprints.fingerprint.capture.usecase import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.fingerprint.IFingerIdentifier import com.simprints.fingerprint.capture.state.CaptureState import com.simprints.fingerprint.capture.state.ScanResult import com.simprints.infra.config.store.models.FingerprintConfiguration @@ -38,6 +39,7 @@ class SaveImageUseCaseTest { MockKAnnotations.init(this, relaxed = true) every { vero2Configuration.imageSavingStrategy } returns Vero2Configuration.ImageSavingStrategy.EAGER + every { vero2Configuration.captureStrategy } returns Vero2Configuration.CaptureStrategy.SECUGEN_ISO_1300_DPI useCase = SaveImageUseCase(imageRepo, eventRepo) } @@ -46,6 +48,7 @@ class SaveImageUseCaseTest { fun `Returns null if no scan image`() = runTest { val result = useCase.invoke( vero2Configuration, + IFingerIdentifier.LEFT_3RD_FINGER, "captureEventId", createCollectedStub(null) ) @@ -56,6 +59,7 @@ class SaveImageUseCaseTest { fun `Returns null if no capture event id`() = runTest { val result = useCase.invoke( vero2Configuration, + IFingerIdentifier.LEFT_3RD_FINGER, null, createCollectedStub(byteArrayOf()) ) @@ -76,11 +80,12 @@ class SaveImageUseCaseTest { "captureEventId.wsq" )) coEvery { - imageRepo.storeImageSecurely(any(), "projectId", any()) + imageRepo.storeImageSecurely(any(), "projectId", any(), any()) } returns SecuredImageRef(expectedPath) assertThat(useCase.invoke( vero2Configuration, + IFingerIdentifier.LEFT_3RD_FINGER, "captureEventId", createCollectedStub(byteArrayOf()) )).isNotNull() @@ -91,7 +96,8 @@ class SaveImageUseCaseTest { "projectId", withArg { assert(expectedPath.compose().contains(it.compose())) - } + }, + any() ) } } @@ -102,6 +108,7 @@ class SaveImageUseCaseTest { assertThat(useCase.invoke( vero2Configuration, + IFingerIdentifier.LEFT_3RD_FINGER, "captureEventId", createCollectedStub(byteArrayOf()) )).isNull() @@ -114,16 +121,17 @@ class SaveImageUseCaseTest { every { id } returns "sessionId" } coEvery { - imageRepo.storeImageSecurely(any(), "projectId", any()) + imageRepo.storeImageSecurely(any(), "projectId", any(), any()) } returns null assertThat(useCase.invoke( vero2Configuration, + IFingerIdentifier.LEFT_3RD_FINGER, "captureEventId", createCollectedStub(byteArrayOf()) )).isNull() - coVerify { imageRepo.storeImageSecurely(any(), "projectId", any()) } + coVerify { imageRepo.storeImageSecurely(any(), "projectId", any(), any()) } } private fun createCollectedStub(image: ByteArray?) = CaptureState.Collected( diff --git a/infra/images/build.gradle.kts b/infra/images/build.gradle.kts index bf6740850b..5ed3944f35 100644 --- a/infra/images/build.gradle.kts +++ b/infra/images/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("simprints.infra") + id("simprints.library.room") id("kotlin-parcelize") id("simprints.testing.android") } diff --git a/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt b/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt index 62a228ae07..4b2b9af2e9 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt @@ -14,13 +14,16 @@ interface ImageRepository { * @param imageBytes the image, in bytes * @param projectId the id of the project * @param relativePath the path of the image within the images root folder, including file name + * @param metadata arbitrary key-value pairs to be associated with the image + * * @return a reference to the newly stored image, if successful, otherwise null * @see [com.simprints.infra.images.local.ImageLocalDataSource.encryptAndStoreImage] */ suspend fun storeImageSecurely( imageBytes: ByteArray, projectId: String, - relativePath: Path + relativePath: Path, + metadata: Map = emptyMap(), ): SecuredImageRef? /** diff --git a/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt b/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt index 6db5067cda..a4133f1bfc 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt @@ -1,22 +1,29 @@ package com.simprints.infra.images import com.simprints.infra.images.local.ImageLocalDataSource +import com.simprints.infra.images.metadata.ImageMetadataStore import com.simprints.infra.images.model.Path import com.simprints.infra.images.model.SecuredImageRef import com.simprints.infra.images.remote.ImageRemoteDataSource import com.simprints.infra.logging.Simber import javax.inject.Inject -class ImageRepositoryImpl @Inject internal constructor( +internal class ImageRepositoryImpl @Inject internal constructor( private val localDataSource: ImageLocalDataSource, private val remoteDataSource: ImageRemoteDataSource, + private val metadataStore: ImageMetadataStore, ) : ImageRepository { override suspend fun storeImageSecurely( imageBytes: ByteArray, projectId: String, relativePath: Path, - ): SecuredImageRef? = localDataSource.encryptAndStoreImage(imageBytes, projectId, relativePath) + metadata: Map, + ): SecuredImageRef? { + return localDataSource.encryptAndStoreImage(imageBytes, projectId, relativePath) + // Only store metadata if the image was stored successfully + ?.also { metadataStore.storeMetadata(relativePath, metadata) } + } override suspend fun getNumberOfImagesToUpload(projectId: String): Int = localDataSource.listImages(projectId).count() @@ -29,9 +36,11 @@ class ImageRepositoryImpl @Inject internal constructor( images.forEach { imageRef -> try { localDataSource.decryptImage(imageRef)?.let { stream -> - val uploadResult = remoteDataSource.uploadImage(stream, imageRef) + val metadata = metadataStore.getMetadata(imageRef.relativePath) + val uploadResult = remoteDataSource.uploadImage(stream, imageRef, metadata) if (uploadResult.isUploadSuccessful()) { localDataSource.deleteImage(imageRef) + metadataStore.deleteMetadata(imageRef.relativePath) } else { allImagesUploaded = false Simber.e("Failed to upload image without exception") @@ -47,6 +56,7 @@ class ImageRepositoryImpl @Inject internal constructor( } override suspend fun deleteStoredImages() { + metadataStore.deleteAllMetadata() for (image in localDataSource.listImages(null)) { localDataSource.deleteImage(image) } diff --git a/infra/images/src/main/java/com/simprints/infra/images/ImagesModule.kt b/infra/images/src/main/java/com/simprints/infra/images/ImagesModule.kt index 8a45f9cfd3..2f923f49d3 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/ImagesModule.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/ImagesModule.kt @@ -1,13 +1,19 @@ package com.simprints.infra.images +import android.content.Context import com.simprints.infra.images.local.ImageLocalDataSource import com.simprints.infra.images.local.ImageLocalDataSourceImpl +import com.simprints.infra.images.metadata.database.ImageMetadataDao +import com.simprints.infra.images.metadata.database.ImageMetadataDatabase import com.simprints.infra.images.remote.ImageRemoteDataSource import com.simprints.infra.images.remote.ImageRemoteDataSourceImpl import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -23,3 +29,16 @@ abstract class ImagesModule { internal abstract fun bindImageRemoteDataSource(impl: ImageRemoteDataSourceImpl): ImageRemoteDataSource } + +@Module +@InstallIn(SingletonComponent::class) +internal class ImageModuleProviders { + + @Provides + @Singleton + fun provideImageMetadataDatabase(@ApplicationContext ctx: Context): ImageMetadataDatabase = ImageMetadataDatabase.getDatabase(ctx) + + @Provides + @Singleton + fun provideImageMetadataDao(database: ImageMetadataDatabase): ImageMetadataDao = database.imageMetadataDao +} diff --git a/infra/images/src/main/java/com/simprints/infra/images/metadata/ImageMetadataStore.kt b/infra/images/src/main/java/com/simprints/infra/images/metadata/ImageMetadataStore.kt new file mode 100644 index 0000000000..a539a6c6ba --- /dev/null +++ b/infra/images/src/main/java/com/simprints/infra/images/metadata/ImageMetadataStore.kt @@ -0,0 +1,27 @@ +package com.simprints.infra.images.metadata + +import com.simprints.infra.images.metadata.database.DbImageMetadata +import com.simprints.infra.images.metadata.database.ImageMetadataDao +import com.simprints.infra.images.model.Path +import javax.inject.Inject + +internal class ImageMetadataStore @Inject constructor( + private val imageMetadataDao: ImageMetadataDao, +) { + + suspend fun storeMetadata(imageKey: Path, metadata: Map) = metadata + .takeIf { it.isNotEmpty() } + ?.map { (k, v) -> DbImageMetadata(imageId = extractKey(imageKey), key = k, value = v) } + ?.let { imageMetadataDao.save(it) } + + suspend fun getMetadata(imageKey: Path): Map = imageMetadataDao + .get(extractKey(imageKey)) + .let { metadata -> metadata.associate { it.key to it.value } } + + suspend fun deleteMetadata(imageKey: Path) = imageMetadataDao.delete(extractKey(imageKey)) + + suspend fun deleteAllMetadata() = imageMetadataDao.deleteAll() + + // Using only the file name as the key to avoid confusion with the path root + private fun extractKey(key: Path) = key.parts.last() +} diff --git a/infra/images/src/main/java/com/simprints/infra/images/metadata/database/DbImageMetadata.kt b/infra/images/src/main/java/com/simprints/infra/images/metadata/database/DbImageMetadata.kt new file mode 100644 index 0000000000..81e2b3ff8c --- /dev/null +++ b/infra/images/src/main/java/com/simprints/infra/images/metadata/database/DbImageMetadata.kt @@ -0,0 +1,15 @@ +package com.simprints.infra.images.metadata.database + +import androidx.room.Entity +import com.google.errorprone.annotations.Keep + +@Entity( + tableName = "DbImageMetadata", + primaryKeys = ["imageId", "key"], +) +@Keep +internal data class DbImageMetadata( + val imageId: String, + val key: String, + val value: String, +) diff --git a/infra/images/src/main/java/com/simprints/infra/images/metadata/database/ImageMetadataDao.kt b/infra/images/src/main/java/com/simprints/infra/images/metadata/database/ImageMetadataDao.kt new file mode 100644 index 0000000000..efaeff1383 --- /dev/null +++ b/infra/images/src/main/java/com/simprints/infra/images/metadata/database/ImageMetadataDao.kt @@ -0,0 +1,22 @@ +package com.simprints.infra.images.metadata.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +internal interface ImageMetadataDao { + + @Query("SELECT * FROM DbImageMetadata WHERE imageId = :imageId") + suspend fun get(imageId: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(metadata: List) + + @Query("DELETE FROM DbImageMetadata WHERE imageId = :imageId") + suspend fun delete(imageId: String) + + @Query("DELETE FROM DbImageMetadata") + suspend fun deleteAll() +} diff --git a/infra/images/src/main/java/com/simprints/infra/images/metadata/database/ImageMetadataDatabase.kt b/infra/images/src/main/java/com/simprints/infra/images/metadata/database/ImageMetadataDatabase.kt new file mode 100644 index 0000000000..195dfc9146 --- /dev/null +++ b/infra/images/src/main/java/com/simprints/infra/images/metadata/database/ImageMetadataDatabase.kt @@ -0,0 +1,30 @@ +package com.simprints.infra.images.metadata.database + +import android.content.Context +import androidx.room.* +import com.google.errorprone.annotations.Keep + +@Database( + entities = [DbImageMetadata::class], + version = 1, + exportSchema = false +) +@Keep +internal abstract class ImageMetadataDatabase : RoomDatabase() { + + abstract val imageMetadataDao: ImageMetadataDao + + companion object { + + private const val ROOM_DB_NAME = "image_meta_db" + + fun getDatabase(context: Context): ImageMetadataDatabase = Room + .databaseBuilder( + context.applicationContext, + ImageMetadataDatabase::class.java, + ROOM_DB_NAME + ) + .fallbackToDestructiveMigration() + .build() + } +} diff --git a/infra/images/src/main/java/com/simprints/infra/images/remote/ImageRemoteDataSource.kt b/infra/images/src/main/java/com/simprints/infra/images/remote/ImageRemoteDataSource.kt index 9e44056f42..ff76839019 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/remote/ImageRemoteDataSource.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/remote/ImageRemoteDataSource.kt @@ -13,13 +13,15 @@ internal interface ImageRemoteDataSource { * * @param imageStream the image file as a stream * @param imageRef a reference to the image to be uploaded + * @param metadata arbitrary key-value pairs to be associated with the image * * @return the result of the operation. * @see [UploadResult] */ suspend fun uploadImage( imageStream: FileInputStream, - imageRef: SecuredImageRef + imageRef: SecuredImageRef, + metadata: Map, ): UploadResult } diff --git a/infra/images/src/main/java/com/simprints/infra/images/remote/ImageRemoteDataSourceImpl.kt b/infra/images/src/main/java/com/simprints/infra/images/remote/ImageRemoteDataSourceImpl.kt index 499fe1969d..441cbfd3a1 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/remote/ImageRemoteDataSourceImpl.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/remote/ImageRemoteDataSourceImpl.kt @@ -1,6 +1,7 @@ package com.simprints.infra.images.remote import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.StorageMetadata import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.images.model.SecuredImageRef @@ -17,6 +18,7 @@ internal class ImageRemoteDataSourceImpl @Inject constructor( override suspend fun uploadImage( imageStream: FileInputStream, imageRef: SecuredImageRef, + metadata: Map, ): UploadResult { val firebaseProjectName = authStore.getLegacyAppFallback().options.projectId @@ -43,7 +45,14 @@ internal class ImageRemoteDataSourceImpl @Inject constructor( Simber.d("Uploading ${fileRef.path}") - val uploadTask = fileRef.putStream(imageStream).await() + val uploadTask = if (metadata.isEmpty()) { + fileRef.putStream(imageStream).await() + } else { + val storeMetadata = StorageMetadata.Builder() + .also { metadata.forEach { (key, value) -> it.setCustomMetadata(key, value) } } + .build() + fileRef.putStream(imageStream, storeMetadata).await() + } val status = if (uploadTask.task.isSuccessful) { UploadResult.Status.SUCCESSFUL @@ -58,5 +67,4 @@ internal class ImageRemoteDataSourceImpl @Inject constructor( } } - } diff --git a/infra/images/src/test/java/com/simprints/infra/images/ImageRepositoryImplTest.kt b/infra/images/src/test/java/com/simprints/infra/images/ImageRepositoryImplTest.kt index 05531619aa..13448edb8b 100644 --- a/infra/images/src/test/java/com/simprints/infra/images/ImageRepositoryImplTest.kt +++ b/infra/images/src/test/java/com/simprints/infra/images/ImageRepositoryImplTest.kt @@ -2,6 +2,7 @@ package com.simprints.infra.images import com.google.common.truth.Truth.assertThat import com.simprints.infra.images.local.ImageLocalDataSource +import com.simprints.infra.images.metadata.ImageMetadataStore import com.simprints.infra.images.model.Path import com.simprints.infra.images.model.SecuredImageRef import com.simprints.infra.images.remote.ImageRemoteDataSource @@ -9,8 +10,10 @@ import com.simprints.infra.images.remote.UploadResult import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.impl.annotations.MockK +import io.mockk.justRun import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Before @@ -25,15 +28,19 @@ internal class ImageRepositoryImplTest { @MockK lateinit var localDataSource: ImageLocalDataSource + @MockK lateinit var remoteDataSource: ImageRemoteDataSource + @MockK + lateinit var metadataStore: ImageMetadataStore + private lateinit var repository: ImageRepository @Before fun setUp() { MockKAnnotations.init(this) - repository = ImageRepositoryImpl(localDataSource, remoteDataSource) + repository = ImageRepositoryImpl(localDataSource, remoteDataSource, metadataStore) initValidImageMocks() } @@ -88,6 +95,7 @@ internal class ImageRepositoryImplTest { coVerify(exactly = 3) { localDataSource.decryptImage(any()) } coVerify(exactly = 3) { localDataSource.deleteImage(any()) } + coVerify(exactly = 3) { metadataStore.deleteMetadata(any()) } assertThat(successful).isTrue() } @@ -107,6 +115,7 @@ internal class ImageRepositoryImplTest { repository.deleteStoredImages() coVerify(exactly = 5) { localDataSource.deleteImage(any()) } + coVerify(exactly = 1) { metadataStore.deleteAllMetadata() } } @Test @@ -131,8 +140,13 @@ internal class ImageRepositoryImplTest { localDataSource.decryptImage(validImage) } returns mockStream + coJustRun { metadataStore.storeMetadata(any(), any()) } + coEvery { metadataStore.getMetadata(any()) } returns emptyMap() + coJustRun { metadataStore.deleteMetadata(any()) } + coJustRun { metadataStore.deleteAllMetadata() } + coEvery { - remoteDataSource.uploadImage(mockStream, validImage) + remoteDataSource.uploadImage(mockStream, validImage, emptyMap()) } returns UploadResult( validImage, UploadResult.Status.SUCCESSFUL @@ -148,7 +162,7 @@ internal class ImageRepositoryImplTest { } returns mockStream coEvery { - remoteDataSource.uploadImage(mockStream, invalidImage) + remoteDataSource.uploadImage(mockStream, invalidImage, emptyMap()) } returns UploadResult( invalidImage, UploadResult.Status.FAILED diff --git a/infra/images/src/test/java/com/simprints/infra/images/metadata/ImageMetadataStoreTest.kt b/infra/images/src/test/java/com/simprints/infra/images/metadata/ImageMetadataStoreTest.kt new file mode 100644 index 0000000000..ec046d4cf6 --- /dev/null +++ b/infra/images/src/test/java/com/simprints/infra/images/metadata/ImageMetadataStoreTest.kt @@ -0,0 +1,89 @@ +package com.simprints.infra.images.metadata + +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.images.metadata.database.DbImageMetadata +import com.simprints.infra.images.metadata.database.ImageMetadataDao +import com.simprints.infra.images.model.Path +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class ImageMetadataStoreTest { + + @MockK + private lateinit var metadataDao: ImageMetadataDao + + private lateinit var metadataStore: ImageMetadataStore + + @Before + fun setUp() { + MockKAnnotations.init(this) + + metadataStore = ImageMetadataStore(metadataDao) + } + + @Test + fun `store metadata`() = runTest { + coJustRun { metadataDao.save(any()) } + + metadataStore.storeMetadata( + Path("path/imageKey"), + mapOf("key1" to "value1", "key2" to "value2") + ) + + coVerify { + metadataDao.save( + listOf( + DbImageMetadata("imageKey", "key1", "value1"), + DbImageMetadata("imageKey", "key2", "value2") + ) + ) + } + } + + @Test + fun `store empty metadata`() = runTest { + metadataStore.storeMetadata(Path("path/imageKey"), emptyMap()) + + coVerify(exactly = 0) { metadataDao.save(any()) } + } + + @Test + fun `getting metadata`() = runTest { + coEvery { metadataDao.get("imageKey") } returns listOf( + DbImageMetadata("imageKey", "key1", "value1"), + DbImageMetadata("imageKey", "key2", "value2") + ) + + assertThat(metadataStore.getMetadata(Path("imageKey"))) + .containsExactly("key1", "value1", "key2", "value2") + } + + @Test + fun `getting empty metadata`() = runTest { + coEvery { metadataDao.get("imageKey") } returns emptyList() + + assertThat(metadataStore.getMetadata(Path("imageKey"))).isEmpty() + } + + @Test + fun `delete metadata`() = runTest { + coJustRun { metadataDao.delete(any()) } + + metadataStore.deleteMetadata(Path("imageKey")) + coVerify { metadataDao.delete("imageKey") } + } + + @Test + fun `delete all metadata`() = runTest { + coJustRun { metadataDao.deleteAll() } + + metadataStore.deleteAllMetadata() + coVerify { metadataDao.deleteAll() } + } +} diff --git a/infra/images/src/test/java/com/simprints/infra/images/remote/ImageRemoteDataSourceImplTest.kt b/infra/images/src/test/java/com/simprints/infra/images/remote/ImageRemoteDataSourceImplTest.kt index 2c6b626145..390ae07226 100644 --- a/infra/images/src/test/java/com/simprints/infra/images/remote/ImageRemoteDataSourceImplTest.kt +++ b/infra/images/src/test/java/com/simprints/infra/images/remote/ImageRemoteDataSourceImplTest.kt @@ -69,7 +69,22 @@ class ImageRemoteDataSourceImplTest { every { FirebaseStorage.getInstance(any(), any()) } returns storageMock - val result = remoteDataSource.uploadImage(mockImageStream, mockSecuredImageRef) + val result = remoteDataSource.uploadImage(mockImageStream, mockSecuredImageRef, emptyMap()) + + assertThat(result.isUploadSuccessful()).isTrue() + } + + @Test + fun `test image with metadata upload flow`() = runTest { + coEvery { configManager.getProject(any()).imageBucket } returns "gs://`simprints-dev.appspot.com" + every { authStore.getLegacyAppFallback().options.projectId } returns "projectId" + every { authStore.signedInProjectId } returns "projectId" + + val storageMock = setupStorageMock() + + every { FirebaseStorage.getInstance(any(), any()) } returns storageMock + + val result = remoteDataSource.uploadImage(mockImageStream, mockSecuredImageRef, mapOf("key" to "value")) assertThat(result.isUploadSuccessful()).isTrue() } @@ -78,7 +93,7 @@ class ImageRemoteDataSourceImplTest { fun `null project returns failed upload`() = runTest { every { authStore.getLegacyAppFallback().options.projectId } returns null - val result = remoteDataSource.uploadImage(mockImageStream, mockSecuredImageRef) + val result = remoteDataSource.uploadImage(mockImageStream, mockSecuredImageRef, emptyMap()) assertThat(result.isUploadSuccessful()).isFalse() } @@ -88,7 +103,7 @@ class ImageRemoteDataSourceImplTest { coEvery { configManager.getProject(any()).imageBucket } returns "" every { authStore.getLegacyAppFallback().options.projectId } returns "projectId" - val result = remoteDataSource.uploadImage(mockImageStream, mockSecuredImageRef) + val result = remoteDataSource.uploadImage(mockImageStream, mockSecuredImageRef, emptyMap()) assertThat(result.isUploadSuccessful()).isFalse() } @@ -103,6 +118,13 @@ class ImageRemoteDataSourceImplTest { } } } + every { putStream(any(), any()) } returns mockk { + coEvery { await() } returns mockk { + every { task } returns mockk { + every { isSuccessful } returns true + } + } + } } }