diff --git a/app/build.gradle b/app/build.gradle index 3944891d..11a443ba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -136,6 +136,7 @@ dependencies { // Test testImplementation 'junit:junit:4.13' + testImplementation 'com.nitorcreations:matchers:1.3' testImplementation "org.mockito:mockito-core:$mockito_version" testImplementation "org.mockito:mockito-inline:$mockito_version" testImplementation 'com.nhaarman:mockito-kotlin:1.6.0' diff --git a/app/src/main/java/net/aiscope/gdd_app/extensions/Collections.kt b/app/src/main/java/net/aiscope/gdd_app/extensions/Collections.kt deleted file mode 100644 index 3db66a5e..00000000 --- a/app/src/main/java/net/aiscope/gdd_app/extensions/Collections.kt +++ /dev/null @@ -1,12 +0,0 @@ -package net.aiscope.gdd_app.extensions - -operator fun LinkedHashSet.plus(element: T): LinkedHashSet { - val result = LinkedHashSet() - result.addAll(this) - result.add(element) - return result -} - -fun Iterable.toLinkedHashSet(): LinkedHashSet { - return toCollection(LinkedHashSet()) -} diff --git a/app/src/main/java/net/aiscope/gdd_app/extensions/CollectionsExt.kt b/app/src/main/java/net/aiscope/gdd_app/extensions/CollectionsExt.kt new file mode 100644 index 00000000..e1c06b57 --- /dev/null +++ b/app/src/main/java/net/aiscope/gdd_app/extensions/CollectionsExt.kt @@ -0,0 +1,5 @@ +package net.aiscope.gdd_app.extensions + + +fun List.replaceElementAt(index: Int, value: T) = + this.slice(0 until index) + value + this.slice(index + 1 until this.size) diff --git a/app/src/main/java/net/aiscope/gdd_app/model/SampleModels.kt b/app/src/main/java/net/aiscope/gdd_app/model/SampleModels.kt index 25531bfb..3511c669 100644 --- a/app/src/main/java/net/aiscope/gdd_app/model/SampleModels.kt +++ b/app/src/main/java/net/aiscope/gdd_app/model/SampleModels.kt @@ -1,9 +1,8 @@ package net.aiscope.gdd_app.model -import net.aiscope.gdd_app.extensions.plus +import net.aiscope.gdd_app.extensions.replaceElementAt import java.io.File import java.util.Calendar -import java.util.LinkedHashSet data class Sample( val id: String, @@ -12,20 +11,19 @@ data class Sample( val disease: String, val preparation: SamplePreparation? = null, val microscopeQuality: MicroscopeQuality? = null, - val images: LinkedHashSet = linkedSetOf(), - val masks: LinkedHashSet = linkedSetOf(), + val captures: Captures = Captures(), val metadata: SampleMetadata = SampleMetadata(), val status: SampleStatus = SampleStatus.Incomplete, val createdOn: Calendar = Calendar.getInstance(), val lastModified: Calendar = Calendar.getInstance() ) { - fun addImage(path: File) = copy(images = images + path) + fun addNewlyCapturedImage(path: File) = copy(captures = captures.newCapture(path)) - fun addMask(path: File) = copy(masks = masks + path) + fun upsertMask(path: File, isEmpty: Boolean) = copy(captures = captures.upsertMask(path, isEmpty)) - fun nextImageName(): String = "${id}_image_${images.size}" + fun nextImageName(): String = "${id}_image_${captures.completedCaptureCount()}" - fun nextMaskName(): String = "${id}_mask_${images.size}" + fun nextMaskName(): String = "${id}_mask_${captures.completedCaptureCount()}" } enum class SampleStatus(val id: Short) { @@ -56,6 +54,53 @@ data class MicroscopeQuality( val magnification: Int ) +data class Captures( + val inProgressCapture: InProgressCapture?, + val completedCaptures: List +) { + + constructor() : this(null, emptyList()) + + fun newCapture(path: File): Captures = Captures(InProgressCapture(path), completedCaptures) + + fun upsertMask(maskPath: File, isEmpty: Boolean): Captures { + val indexOfExistingCaptureForMask = completedCaptures.indexOfFirst { it.mask == maskPath } + + return when { + indexOfExistingCaptureForMask >= 0 && inProgressCapture != null -> + // we somehow broke the business logic + throw IllegalStateException( + "Trying to add mask $maskPath at index $indexOfExistingCaptureForMask " + + "when inProgressCapture is not null ($inProgressCapture)" + ) + inProgressCapture != null -> // indexOfCaptureIfMaskExists < 0 + Captures( + null, + completedCaptures + CompletedCapture( + inProgressCapture.image, maskPath, isEmpty + ) + ) + else -> // inProgressCapture == null && indexOfCaptureIfMaskExists < 0 + Captures( + null, + completedCaptures.replaceElementAt( + indexOfExistingCaptureForMask, + completedCaptures[indexOfExistingCaptureForMask].copy(maskIsEmpty = isEmpty) + ) + ) + } + } + + fun completedCaptureCount(): Int { + return completedCaptures.size + } + +} + +sealed class Capture +data class InProgressCapture(val image: File) : Capture() +data class CompletedCapture(val image: File, val mask: File, val maskIsEmpty: Boolean) : Capture() + data class SampleMetadata( val smearType: SmearType = SmearType.THIN, val species: MalariaSpecies = MalariaSpecies.P_FALCIPARUM, diff --git a/app/src/main/java/net/aiscope/gdd_app/network/FirebaseRemoteStorage.kt b/app/src/main/java/net/aiscope/gdd_app/network/FirebaseRemoteStorage.kt index 9a84365e..a7fbaafe 100644 --- a/app/src/main/java/net/aiscope/gdd_app/network/FirebaseRemoteStorage.kt +++ b/app/src/main/java/net/aiscope/gdd_app/network/FirebaseRemoteStorage.kt @@ -10,12 +10,9 @@ class FirebaseRemoteStorage(private val uploader: FirebaseStorageUploader, priva uploader.upload(gson.toJson(sample.toDto()), jsonKey) - sample.images.forEachIndexed { index, image -> - uploader.upload(image, "${sample.id}/image_${index}.jpg") - } - - sample.masks.forEachIndexed { index, mask -> - uploader.upload(mask, "${sample.id}/mask_${index}.png") + sample.captures.completedCaptures.forEachIndexed { index, capture -> + uploader.upload(capture.image, "${sample.id}/image_${index}.jpg") + uploader.upload(capture.mask, "${sample.id}/mask_${index}.png") } } } diff --git a/app/src/main/java/net/aiscope/gdd_app/repository/SampleDtos.kt b/app/src/main/java/net/aiscope/gdd_app/repository/SampleDtos.kt index 936b83b4..38447b38 100644 --- a/app/src/main/java/net/aiscope/gdd_app/repository/SampleDtos.kt +++ b/app/src/main/java/net/aiscope/gdd_app/repository/SampleDtos.kt @@ -1,7 +1,9 @@ package net.aiscope.gdd_app.repository import com.google.gson.annotations.SerializedName -import net.aiscope.gdd_app.extensions.toLinkedHashSet +import net.aiscope.gdd_app.model.CompletedCapture +import net.aiscope.gdd_app.model.Captures +import net.aiscope.gdd_app.model.InProgressCapture import net.aiscope.gdd_app.model.MalariaSpecies import net.aiscope.gdd_app.model.MicroscopeQuality import net.aiscope.gdd_app.model.Sample @@ -22,25 +24,42 @@ data class SampleDto( @SerializedName("microscopeQuality") val microscopeQuality: MicroscopeQualityDto?, @SerializedName("imagePaths") val imagePaths: List, @SerializedName("maskPaths") val maskPaths: List, + @SerializedName("areMasksEmpty") val areMasksEmpty: List, @SerializedName("metadata") val metadata: SampleMetadataDto, @SerializedName("status") val status: Short, @SerializedName("createdOn") val createdOn: Calendar = Calendar.getInstance(), - @SerializedName("lastModified") val lastModified : Calendar = Calendar.getInstance() + @SerializedName("lastModified") val lastModified: Calendar = Calendar.getInstance() ) { - fun toDomain(): Sample = Sample( - id = id, - healthFacility = healthFacility, - microscopist = microscopist, - disease = disease, - preparation = preparation?.toDomain(), - microscopeQuality = microscopeQuality?.toDomain(), - images = imagePaths.map { File(it) }.toLinkedHashSet(), - masks = maskPaths.map { File(it) }.toLinkedHashSet(), - metadata = metadata.toDomain(), - status = SampleStatus.values().first { it.id == status }, - createdOn = createdOn, - lastModified = lastModified - ) + fun toDomain(): Sample { + val areMasksEmpty = backfillAreMaskEmpty() + val completedCaptures = buildCompletedCaptures(areMasksEmpty) + val inProgressCapture = extractInProgressCapture() + + return Sample( + id = id, + healthFacility = healthFacility, + microscopist = microscopist, + disease = disease, + preparation = preparation?.toDomain(), + microscopeQuality = microscopeQuality?.toDomain(), + captures = Captures(inProgressCapture, completedCaptures), + metadata = metadata.toDomain(), + status = SampleStatus.values().first { it.id == status }, + createdOn = createdOn, + lastModified = lastModified + ) + } + + private fun backfillAreMaskEmpty() = + if (areMasksEmpty.isNotEmpty()) areMasksEmpty else List(imagePaths.size) { false } + + private fun buildCompletedCaptures(areMasksEmpty: List) = + imagePaths.zip(maskPaths).zip(areMasksEmpty) { filesPair, emptyMask -> + CompletedCapture(File(filesPair.first), File(filesPair.second), emptyMask) + } + + private fun extractInProgressCapture() = + if (imagePaths.size > maskPaths.size) InProgressCapture(File(imagePaths.last())) else null } data class SamplePreparationDto( @@ -91,8 +110,10 @@ fun Sample.toDto() = SampleDto( disease = disease, preparation = preparation?.toDto(), microscopeQuality = microscopeQuality?.toDto(), - imagePaths = images.map { it.absolutePath }, - maskPaths = masks.map { it.absolutePath }, + imagePaths = captures.completedCaptures.map { it.image.absolutePath } + + listOfNotNull(captures.inProgressCapture?.image?.absolutePath), + maskPaths = captures.completedCaptures.map { it.mask.absolutePath }, + areMasksEmpty = captures.completedCaptures.map { it.maskIsEmpty }, metadata = metadata.toDto(), status = status.id, createdOn = createdOn, diff --git a/app/src/main/java/net/aiscope/gdd_app/ui/capture/CaptureImagePresenter.kt b/app/src/main/java/net/aiscope/gdd_app/ui/capture/CaptureImagePresenter.kt index e5bf1277..14f0a10c 100644 --- a/app/src/main/java/net/aiscope/gdd_app/ui/capture/CaptureImagePresenter.kt +++ b/app/src/main/java/net/aiscope/gdd_app/ui/capture/CaptureImagePresenter.kt @@ -16,7 +16,7 @@ class CaptureImagePresenter( if (file == null) { view.notifyImageCouldNotBeTaken() } else { - val sample = repository.current().addImage(file) + val sample = repository.current().addNewlyCapturedImage(file) repository.store(sample) view.goToMask(sample.disease, file.absolutePath, sample.nextMaskName()) diff --git a/app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskActivity.kt b/app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskActivity.kt index 3d4dbe2b..23f4a119 100644 --- a/app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskActivity.kt +++ b/app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskActivity.kt @@ -91,7 +91,9 @@ class MaskActivity : AppCompatActivity(), MaskView, CaptureFlow { stagesBtn.setOnClickListener { selectStagePopup.show() } - getBitmapBtn.setOnClickListener { presenter.handleCaptureBitmap(maskNameExtra) } + getBitmapBtn.setOnClickListener { + presenter.handleCaptureBitmap(maskNameExtra, isEmptyMaskBitmap()) + } photoMaskView.onMaskingActionFinishedListener = View.OnTouchListener { _, _ -> setEnabled(undoBtn, photoMaskView.undoAvailable()) @@ -190,4 +192,11 @@ class MaskActivity : AppCompatActivity(), MaskView, CaptureFlow { mutable = mutable ) } + + private fun isEmptyMaskBitmap(): Boolean { + val maskBitmap = binding.photoMaskView.getMaskBitmap() + val emptyBitmap: Bitmap = + Bitmap.createBitmap(maskBitmap.width, maskBitmap.height, maskBitmap.config) + return maskBitmap.sameAs(emptyBitmap) + } } diff --git a/app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskPresenter.kt b/app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskPresenter.kt index 107778a4..f15ed9de 100644 --- a/app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskPresenter.kt +++ b/app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskPresenter.kt @@ -19,12 +19,12 @@ class MaskPresenter( ) } - fun handleCaptureBitmap(maskName: String) { + fun handleCaptureBitmap(maskName: String, isEmptyMask: Boolean) { view.takeMask(maskName) { file -> if (file == null) { view.notifyImageCouldNotBeTaken() } else { - val sample = repository.current().addMask(file) + val sample = repository.current().upsertMask(file, isEmptyMask) repository.store(sample) view.goToMetadata() @@ -38,7 +38,6 @@ class MaskPresenter( } companion object { - private fun composeBrushDiseaseStagesArray( diseaseName: String, resources: Resources diff --git a/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataActivity.kt b/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataActivity.kt index 44d942cd..c05a4402 100644 --- a/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataActivity.kt +++ b/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataActivity.kt @@ -3,7 +3,6 @@ package net.aiscope.gdd_app.ui.metadata import android.content.Intent import android.os.Bundle import android.view.View -import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager @@ -13,6 +12,7 @@ import kotlinx.coroutines.launch import net.aiscope.gdd_app.R import net.aiscope.gdd_app.databinding.ActivityMetadataBinding import net.aiscope.gdd_app.extensions.select +import net.aiscope.gdd_app.model.CompletedCapture import net.aiscope.gdd_app.ui.CaptureFlow import net.aiscope.gdd_app.ui.attachCaptureFlowToolbar import net.aiscope.gdd_app.ui.capture.CaptureImageActivity @@ -21,7 +21,6 @@ import net.aiscope.gdd_app.ui.mask.MaskActivity import net.aiscope.gdd_app.ui.showConfirmExitDialog import net.aiscope.gdd_app.ui.snackbar.CustomSnackbar import net.aiscope.gdd_app.ui.snackbar.CustomSnackbarAction -import java.io.File import javax.inject.Inject @Suppress("TooManyFunctions") @@ -69,8 +68,7 @@ class MetadataActivity : AppCompatActivity(), MetadataView, CaptureFlow { } override fun fillForm(model: ViewStateModel) { - imagesAdapter.setImages(model.images) - imagesAdapter.setMasks(model.masks) + imagesAdapter.setCaptures(model.captures) model.smearTypeId?.let { binding.metadataSectionSmearTypeRadioGroup.check(it) } model.speciesValue?.let { binding.metadataSpeciesSpinner.select(it) } } @@ -96,15 +94,15 @@ class MetadataActivity : AppCompatActivity(), MetadataView, CaptureFlow { this.startActivity(intent) } - override fun editImage(disease: String, image: File, mask: File){ + override fun editCapture(disease: String, capture: CompletedCapture){ val intent = Intent(this, MaskActivity::class.java) intent.putExtra(MaskActivity.EXTRA_DISEASE_NAME, disease) - intent.putExtra(MaskActivity.EXTRA_IMAGE_NAME, image.absolutePath) + intent.putExtra(MaskActivity.EXTRA_IMAGE_NAME, capture.image.absolutePath) //Remove file extension - val maskName = mask.name.removeSuffix(".png") + val maskName = capture.mask.name.removeSuffix(".png") intent.putExtra(MaskActivity.EXTRA_MASK_NAME, maskName) - intent.putExtra(MaskActivity.EXTRA_MASK_PATH, mask.path) + intent.putExtra(MaskActivity.EXTRA_MASK_PATH, capture.mask.path) startActivity(intent) } @@ -129,9 +127,9 @@ class MetadataActivity : AppCompatActivity(), MetadataView, CaptureFlow { } } - private fun onImageClicked(image: File, mask: File) { + private fun onImageClicked(capture: CompletedCapture) { lifecycleScope.launch { - presenter.editImage(image, mask) + presenter.editImage(capture) } } diff --git a/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataPresenter.kt b/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataPresenter.kt index e7758569..74065540 100644 --- a/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataPresenter.kt +++ b/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataPresenter.kt @@ -1,19 +1,18 @@ package net.aiscope.gdd_app.ui.metadata import android.content.Context +import net.aiscope.gdd_app.model.CompletedCapture import net.aiscope.gdd_app.model.SampleMetadata import net.aiscope.gdd_app.model.SampleStatus import net.aiscope.gdd_app.network.RemoteStorage import net.aiscope.gdd_app.repository.SampleRepository import timber.log.Timber -import java.io.File import javax.inject.Inject data class FieldOption(val id: Long, val title: Int) data class ViewStateModel( val disease: String, - val images: List, - val masks: List, + val captures: List, val options: List, val required: Boolean = true, val smearTypeId: Int? = null, @@ -36,8 +35,7 @@ class MetadataPresenter @Inject constructor( view.fillForm( ViewStateModel( sample.disease, - sample.images.toList(), - sample.masks.toList(), + sample.captures.completedCaptures, emptyList(), smearTypeId = lastMetadata?.let { metadataMapper.getSmearTypeId(it.smearType) }, speciesValue = lastMetadata?.let { metadataMapper.getSpeciesValue(context, it.species) }, @@ -71,8 +69,8 @@ class MetadataPresenter @Inject constructor( view.captureImage(current.nextImageName()) } - suspend fun editImage(image: File, mask: File) { + suspend fun editImage(capture: CompletedCapture) { val current = repository.current() - view.editImage(current.disease, image, mask) + view.editCapture(current.disease, capture) } } diff --git a/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataView.kt b/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataView.kt index e5125d18..a980b337 100644 --- a/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataView.kt +++ b/app/src/main/java/net/aiscope/gdd_app/ui/metadata/MetadataView.kt @@ -1,11 +1,11 @@ package net.aiscope.gdd_app.ui.metadata -import java.io.File +import net.aiscope.gdd_app.model.CompletedCapture interface MetadataView { fun fillForm(model: ViewStateModel) fun captureImage(nextImageName: String) - fun editImage(disease: String, image: File, mask: File) + fun editCapture(disease: String, capture: CompletedCapture) fun finishFlow() fun showRetryBar() } diff --git a/app/src/main/java/net/aiscope/gdd_app/ui/metadata/SampleImagesAdapter.kt b/app/src/main/java/net/aiscope/gdd_app/ui/metadata/SampleImagesAdapter.kt index 68a8c8e6..85824bec 100644 --- a/app/src/main/java/net/aiscope/gdd_app/ui/metadata/SampleImagesAdapter.kt +++ b/app/src/main/java/net/aiscope/gdd_app/ui/metadata/SampleImagesAdapter.kt @@ -1,38 +1,46 @@ package net.aiscope.gdd_app.ui.metadata -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.aiscope.gdd_app.R -import net.aiscope.gdd_app.extensions.writeToFile +import net.aiscope.gdd_app.databinding.ItemMetadataSampleImageBinding +import net.aiscope.gdd_app.model.CompletedCapture import net.aiscope.gdd_app.ui.util.BitmapReader -import net.aiscope.gdd_app.ui.util.MinimumSizeDownSampling -import java.io.File class SampleImagesAdapter( private val uiScope: CoroutineScope, private val onAddImageClicked: () -> Unit, - private val onImageClicked: (File, File) -> Unit + private val onImageClicked: (CompletedCapture) -> Unit ) : RecyclerView.Adapter() { - private val images: MutableList = mutableListOf() - private val masks: MutableList = mutableListOf() + private val captures: MutableList = mutableListOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + val layoutInflater = LayoutInflater.from(parent.context) + return when (viewType) { - R.layout.item_metadata_add_image -> AddImageViewHolder(view, onAddImageClicked) - R.layout.item_metadata_sample_image -> ImageViewHolder(view as ImageView, uiScope, onImageClicked) + R.layout.item_metadata_add_image -> + AddImageViewHolder( + layoutInflater.inflate(viewType, parent, false), + onAddImageClicked + ) + R.layout.item_metadata_sample_image -> { + val binding = ItemMetadataSampleImageBinding.inflate(layoutInflater, parent, false) + CaptureViewHolder( + view = binding.root, + binding = binding, + uiScope = uiScope, + onImageClicked = onImageClicked + ) + + } else -> throw IllegalArgumentException("View type $viewType not known") } } @@ -40,8 +48,8 @@ class SampleImagesAdapter( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is AddImageViewHolder -> {} - is ImageViewHolder -> - holder.bind(images[position - 1], masks[position - 1]) + is CaptureViewHolder -> + holder.bind(captures[position - 1]) else -> throw IllegalArgumentException("View holder ${holder.javaClass} not known") } } @@ -54,18 +62,12 @@ class SampleImagesAdapter( } override fun getItemCount(): Int { - return images.size + 1 + return captures.size + 1 } - fun setImages(images: List) { - this.images.clear() - this.images.addAll(images.reversed()) - this.notifyDataSetChanged() - } - - fun setMasks(masks: List) { - this.masks.clear() - this.masks.addAll(masks.reversed()) + fun setCaptures(captures: List) { + this.captures.clear() + this.captures.addAll(captures.reversed()) this.notifyDataSetChanged() } @@ -79,30 +81,46 @@ private class AddImageViewHolder(view: View, private val onAddImageClicked: () - } } -private class ImageViewHolder( - view: ImageView, private val uiScope: CoroutineScope, onImageClicked: (File, File) -> Unit +private class CaptureViewHolder( + view: View, + private val binding: ItemMetadataSampleImageBinding, + private val uiScope: CoroutineScope, + onImageClicked: (CompletedCapture) -> Unit ) : RecyclerView.ViewHolder(view) { - private lateinit var imageFile: File - private lateinit var maskFile: File + private lateinit var capture: CompletedCapture init { - itemView.setOnClickListener { onImageClicked(imageFile, maskFile) } + itemView.setOnClickListener { onImageClicked(capture) } } - fun bind(image: File, mask: File) { - imageFile = image - maskFile = mask + fun bind(capture: CompletedCapture) { + this.capture = capture (itemView.tag as? Job)?.cancel() itemView.tag = uiScope.launch { - (itemView as ImageView).setImageBitmap(null) - val bitmap = BitmapReader.decodeSampledBitmapAndCache( - image, - itemView.context.resources.getDimensionPixelSize(R.dimen.sample_image_thumbnail_width), - itemView.context.resources.getDimensionPixelSize(R.dimen.sample_image_thumbnail_height), - itemView.context.cacheDir - ) - itemView.setImageBitmap(bitmap) + with(binding) { + sampleImage.setImageBitmap(null) + val imageBmp = BitmapReader.decodeSampledBitmapAndCache( + capture.image, + itemView.context.resources.getDimensionPixelSize(R.dimen.sample_image_thumbnail_width), + itemView.context.resources.getDimensionPixelSize(R.dimen.sample_image_thumbnail_height), + itemView.context.cacheDir + ) + sampleImage.setImageBitmap(imageBmp) + + sampleMask.setImageBitmap(null) + if (!capture.maskIsEmpty) { + val maskBmp = BitmapReader.decodeSampledBitmapAndCache( + capture.mask, + itemView.context.resources.getDimensionPixelSize(R.dimen.sample_image_thumbnail_width), + itemView.context.resources.getDimensionPixelSize(R.dimen.sample_image_thumbnail_height), + itemView.context.cacheDir + ) + sampleMask.setImageBitmap(maskBmp) + } + + maskDot.isVisible = !capture.maskIsEmpty + } } } } diff --git a/app/src/main/java/net/aiscope/gdd_app/ui/util/BitmapReader.kt b/app/src/main/java/net/aiscope/gdd_app/ui/util/BitmapReader.kt index 0adc1cd2..77cbee86 100644 --- a/app/src/main/java/net/aiscope/gdd_app/ui/util/BitmapReader.kt +++ b/app/src/main/java/net/aiscope/gdd_app/ui/util/BitmapReader.kt @@ -6,12 +6,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import net.aiscope.gdd_app.extensions.writeToFile import java.io.File +import java.util.Locale import javax.microedition.khronos.egl.EGL10 import javax.microedition.khronos.egl.EGLConfig import javax.microedition.khronos.egl.EGLContext import javax.microedition.khronos.egl.EGLDisplay -import kotlin.math.max -import kotlin.math.min object BitmapReader { // Texture size should never be smaller than this @@ -55,7 +54,7 @@ object BitmapReader { cacheDir, "${image.nameWithoutExtension}_${reqWidth}x${reqHeight}.${image.extension}" ) - if (cachedImage.exists()) { + if (cachedImage.exists() && cachedImage.lastModified() >= image.lastModified()) { return@withContext BitmapFactory.decodeFile(cachedImage.absolutePath) } val bitmap = decodeSampledBitmapFromResource( @@ -65,7 +64,7 @@ object BitmapReader { ) //Write to cache for future access - bitmap.writeToFile(cachedImage, Bitmap.CompressFormat.JPEG) + bitmap.writeToFile(cachedImage, image.bitmapCompressFormatFromExtension()) bitmap } @@ -146,3 +145,11 @@ suspend fun calculateInSampleSize( } return@withContext inSampleSize } + +private fun File.bitmapCompressFormatFromExtension(): Bitmap.CompressFormat { + return when (this.extension.toLowerCase(Locale.US)) { + "png" -> Bitmap.CompressFormat.PNG + "webp" -> Bitmap.CompressFormat.WEBP + else -> Bitmap.CompressFormat.JPEG + } +} diff --git a/app/src/main/res/drawable/ic_dot.xml b/app/src/main/res/drawable/ic_dot.xml new file mode 100644 index 00000000..58616d69 --- /dev/null +++ b/app/src/main/res/drawable/ic_dot.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/activity_metadata.xml b/app/src/main/res/layout/activity_metadata.xml index 683aa45c..67ce3a8c 100644 --- a/app/src/main/res/layout/activity_metadata.xml +++ b/app/src/main/res/layout/activity_metadata.xml @@ -1,5 +1,4 @@ - - @@ -12,8 +14,8 @@ android:layout_height="wrap_content" android:paddingStart="1dp" android:src="@drawable/ic_add_photo_rounded" - tools:ignore="RtlSymmetry" - android:importantForAccessibility="no" /> + android:importantForAccessibility="no" + tools:ignore="RtlSymmetry" /> + diff --git a/app/src/main/res/layout/item_metadata_sample_image.xml b/app/src/main/res/layout/item_metadata_sample_image.xml index 3cccf02e..d74e96ad 100644 --- a/app/src/main/res/layout/item_metadata_sample_image.xml +++ b/app/src/main/res/layout/item_metadata_sample_image.xml @@ -1,10 +1,46 @@ - + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="top" + android:layout_marginEnd="2dp" + tools:ignore="RtlSymmetry"> + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 3271d1fa..e8bc15f9 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -2,6 +2,8 @@ 72dp 104dp + 16dp + 6dp 80dp 60dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 139422d5..e22988a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,6 +56,7 @@ Comments (optional) Add image Sample image + Masked image Blood sample Smear type Thick diff --git a/app/src/test/java/net/aiscope/gdd_app/model/CapturesTest.kt b/app/src/test/java/net/aiscope/gdd_app/model/CapturesTest.kt new file mode 100644 index 00000000..ec86e7ed --- /dev/null +++ b/app/src/test/java/net/aiscope/gdd_app/model/CapturesTest.kt @@ -0,0 +1,70 @@ +package net.aiscope.gdd_app.model + +import org.junit.Assert.* +import org.junit.Test +import java.io.File + +class CapturesTest { + + companion object { + private const val MASK_1_IS_EMPTY = true + private const val MASK_2_IS_EMPTY = true + + private val image1: File = createTempFile("image1") + private val image2: File = createTempFile("image2") + private val mask1: File = createTempFile("mask1") + private val mask2: File = createTempFile("mask2") + private val areMasksEmpty: List = listOf(MASK_1_IS_EMPTY, MASK_2_IS_EMPTY) + } + + @Test(expected = IllegalStateException::class) + fun `should throw exception when upserting existing mask and there is a capture in progress`() { + val captures = Captures( + InProgressCapture(image2), + listOf(CompletedCapture(image1, mask1, MASK_1_IS_EMPTY)) + ) + + captures.upsertMask(mask1, MASK_1_IS_EMPTY) + } + + @Test + fun `should complete in progress capture when upserting new mask`() { + val captures = Captures( + InProgressCapture(image2), + listOf(CompletedCapture(image1, mask1, MASK_1_IS_EMPTY)) + ) + + val result = captures.upsertMask(mask2, MASK_2_IS_EMPTY) + val expected = Captures( + null, + listOf( + CompletedCapture(image1, mask1, MASK_1_IS_EMPTY), + CompletedCapture(image2, mask2, MASK_2_IS_EMPTY) + ) + ) + + assertEquals(expected, result) + } + + @Test + fun `should update existing mask when upserting and there is no in progress capture`() { + val captures = Captures( + null, + listOf( + CompletedCapture(image1, mask1, MASK_1_IS_EMPTY), + CompletedCapture(image2, mask2, MASK_2_IS_EMPTY) + ) + ) + + val result = captures.upsertMask(mask2, !MASK_2_IS_EMPTY) + val expected = Captures( + null, + listOf( + CompletedCapture(image1, mask1, MASK_1_IS_EMPTY), + CompletedCapture(image2, mask2, !MASK_2_IS_EMPTY) + ) + ) + + assertEquals(expected, result) + } +} diff --git a/app/src/test/java/net/aiscope/gdd_app/presentation/CaptureImagePresenterTest.kt b/app/src/test/java/net/aiscope/gdd_app/presentation/CaptureImagePresenterTest.kt index 54f1db12..3d50b6c6 100644 --- a/app/src/test/java/net/aiscope/gdd_app/presentation/CaptureImagePresenterTest.kt +++ b/app/src/test/java/net/aiscope/gdd_app/presentation/CaptureImagePresenterTest.kt @@ -7,6 +7,7 @@ import com.nhaarman.mockito_kotlin.verify import com.nhaarman.mockito_kotlin.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import net.aiscope.gdd_app.CoroutineTestRule +import net.aiscope.gdd_app.model.Captures import net.aiscope.gdd_app.model.Sample import net.aiscope.gdd_app.repository.SampleRepository import net.aiscope.gdd_app.ui.capture.CaptureImagePresenter @@ -39,7 +40,7 @@ class CaptureImagePresenterTest { val presenter = CaptureImagePresenter(view, repository) presenter.handleCaptureImageButton("any") - verify(repository).store(sample.copy(images = linkedSetOf(file))) + verify(repository).store(sample.copy(captures = Captures().newCapture(file))) } } diff --git a/app/src/test/java/net/aiscope/gdd_app/presentation/MetadataPresenterTest.kt b/app/src/test/java/net/aiscope/gdd_app/presentation/MetadataPresenterTest.kt index 3d1e6104..abda73a5 100644 --- a/app/src/test/java/net/aiscope/gdd_app/presentation/MetadataPresenterTest.kt +++ b/app/src/test/java/net/aiscope/gdd_app/presentation/MetadataPresenterTest.kt @@ -9,6 +9,7 @@ import com.nhaarman.mockito_kotlin.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import net.aiscope.gdd_app.CoroutineTestRule import net.aiscope.gdd_app.R +import net.aiscope.gdd_app.model.Captures import net.aiscope.gdd_app.model.MalariaSpecies import net.aiscope.gdd_app.model.Sample import net.aiscope.gdd_app.model.SampleMetadata @@ -61,7 +62,7 @@ class MetadataPresenterTest { id = "id", healthFacility = "StPau", microscopist = "a microscopist", - images = linkedSetOf(), + captures = Captures(), disease = "malaria" ) ) @@ -113,7 +114,7 @@ class MetadataPresenterTest { id = "idlast", healthFacility = "StPau", microscopist = "a microscopist", - images = linkedSetOf(), + captures = Captures(), disease = "malaria", createdOn = java.util.Calendar.getInstance(), metadata = SampleMetadata( diff --git a/app/src/test/java/net/aiscope/gdd_app/repository/SampleDtoTest.kt b/app/src/test/java/net/aiscope/gdd_app/repository/SampleDtoTest.kt index 3e4959e8..240f80aa 100644 --- a/app/src/test/java/net/aiscope/gdd_app/repository/SampleDtoTest.kt +++ b/app/src/test/java/net/aiscope/gdd_app/repository/SampleDtoTest.kt @@ -1,5 +1,9 @@ package net.aiscope.gdd_app.repository +import com.nitorcreations.Matchers.containsElements +import net.aiscope.gdd_app.model.CompletedCapture +import net.aiscope.gdd_app.model.Captures +import net.aiscope.gdd_app.model.InProgressCapture import net.aiscope.gdd_app.model.MalariaSpecies import net.aiscope.gdd_app.model.MicroscopeQuality import net.aiscope.gdd_app.model.Sample @@ -10,11 +14,49 @@ import net.aiscope.gdd_app.model.SmearType import net.aiscope.gdd_app.model.WaterType import org.junit.Test import org.junit.Assert.assertEquals +import org.junit.Assert.assertThat import java.io.File import java.util.Calendar class SampleDtoTest { + @Test + fun `should map Sample domain model to DTO`() { + val sampleDTO = sample.toDto() + + assertEquals(ID, sampleDTO.id) + assertEquals(HEALTH_FACILITY, sampleDTO.healthFacility) + assertEquals(MICROSCOPIST, sampleDTO.microscopist) + assertEquals(DISEASE, sampleDTO.disease) + assertEquals(samplePreparationDto, sampleDTO.preparation) + assertEquals(microscopeQualityDto, sampleDTO.microscopeQuality) + assertThat(imagesList, containsElements(sampleDTO.imagePaths)) + assertThat(masksList, containsElements(sampleDTO.maskPaths)) + assertEquals(areMasksEmpty, sampleDTO.areMasksEmpty) + assertEquals(metadataDto, sampleDTO.metadata) + assertEquals(status.id, sampleDTO.status) + assertEquals(createdOn, sampleDTO.createdOn) + assertEquals(lastModified, sampleDTO.lastModified) + } + + @Test + fun `should map Sample DTO to domain model`() { + val sample = sampleDto.toDomain() + + assertEquals(ID, sample.id) + assertEquals(HEALTH_FACILITY, sample.healthFacility) + assertEquals(MICROSCOPIST, sample.microscopist) + assertEquals(DISEASE, sample.disease) + assertEquals(samplePreparation, sample.preparation) + assertEquals(microscopeQuality, sample.microscopeQuality) + assertEquals(images, sample.captures) + assertEquals(metadata, sample.metadata) + assertEquals(status, sample.status) + assertEquals(createdOn, sample.createdOn) + assertEquals(lastModified, sample.lastModified) + } + + companion object { private const val COMMENTS = "some-comments" private const val DISEASE = "some-disease" @@ -28,80 +70,91 @@ class SampleDtoTest { private const val USES_ALCOHOL = true private const val USES_GIEMSA = true private const val USES_PBS = true + private const val MASK_1_IS_EMPTY = true + private const val MASK_2_IS_EMPTY = true + + private val image1: File = createTempFile("image1") + private val image2: File = createTempFile("image2") + private val imageInProgress: File = createTempFile("imageInProgress") + private val mask1: File = createTempFile("mask1") + private val mask2: File = createTempFile("mask2") + private val areMasksEmpty: List = listOf(MASK_1_IS_EMPTY, MASK_2_IS_EMPTY) private val createdOn: Calendar = Calendar.getInstance() - private val images: LinkedHashSet = linkedSetOf() + private val images = Captures( + InProgressCapture(imageInProgress), listOf( + CompletedCapture(image1, mask1, MASK_1_IS_EMPTY), + CompletedCapture(image2, mask2, MASK_2_IS_EMPTY) + ) + ) + private val imagesList: List = + listOf(image1.absolutePath, image2.absolutePath, imageInProgress.absolutePath) private val lastModified: Calendar = Calendar.getInstance() - private val masks: LinkedHashSet = linkedSetOf() + private val masksList: List = listOf(mask1.absolutePath, mask2.absolutePath) private val smearType = SmearType.THIN private val species = MalariaSpecies.P_FALCIPARUM private val status = SampleStatus.ReadyToUpload private val waterType = WaterType.BOTTLED - val metadata = SampleMetadata(smearType, species, COMMENTS) - val microscopeQuality = MicroscopeQuality(IS_DAMAGED, MAGNIFICATION) - val samplePreparation = SamplePreparation(waterType, USES_GIEMSA, GIEMSA_FP, USES_PBS, USES_ALCOHOL, REUSES_SLIDES) + private val metadata = SampleMetadata(smearType, species, COMMENTS) + private val microscopeQuality = MicroscopeQuality(IS_DAMAGED, MAGNIFICATION) + private val samplePreparation = SamplePreparation( + waterType, + USES_GIEMSA, + GIEMSA_FP, + USES_PBS, + USES_ALCOHOL, + REUSES_SLIDES + ) - val sample = Sample( + private val sample = Sample( id = ID, healthFacility = HEALTH_FACILITY, microscopist = MICROSCOPIST, disease = DISEASE, preparation = samplePreparation, microscopeQuality = microscopeQuality, - images = images, - masks = masks, + captures = images, metadata = metadata, status = status, createdOn = createdOn, lastModified = lastModified ) - } - @Test - fun `should map Sample model to DTO`() { - val sampleDTO = sample.toDto() - - assertEquals(sample.id, sampleDTO.id) - assertEquals(sample.healthFacility, sampleDTO.healthFacility) - assertEquals(sample.microscopist, sampleDTO.microscopist) - assertEquals(sample.disease, sampleDTO.disease) - assertEquals(sample.preparation?.toDto(), sampleDTO.preparation) - assertEquals(sample.microscopeQuality?.toDto(), sampleDTO.microscopeQuality) - assertEquals(sample.images.map { it.absolutePath }, sampleDTO.imagePaths) - assertEquals(sample.masks.map { it.absolutePath }, sampleDTO.maskPaths) - assertEquals(sample.metadata.toDto(), sampleDTO.metadata) - assertEquals(sample.status.id, sampleDTO.status) - assertEquals(sample.createdOn, sampleDTO.createdOn) - assertEquals(sample.lastModified, sampleDTO.lastModified) - } - - @Test - fun `should map SamplePreparation model to DTO`() { - val samplePreparationDTO = samplePreparation.toDto() - - assertEquals(samplePreparation.waterType.id, samplePreparationDTO.waterType) - assertEquals(samplePreparation.usesGiemsa, samplePreparationDTO.usesGiemsa) - assertEquals(samplePreparation.giemsaFP, samplePreparationDTO.giemsaFP) - assertEquals(samplePreparation.usesPbs, samplePreparationDTO.usesPbs) - assertEquals(samplePreparation.usesAlcohol, samplePreparationDTO.usesAlcohol) - assertEquals(samplePreparation.reusesSlides, samplePreparationDTO.reusesSlides) - } - - @Test - fun `should map MicroscopeQuality model to DTO`() { - val microscopeQualityDTO = microscopeQuality.toDto() + private val samplePreparationDto = SamplePreparationDto( + waterType = waterType.id, + usesGiemsa = USES_GIEMSA, + giemsaFP = GIEMSA_FP, + usesPbs = USES_PBS, + usesAlcohol = USES_ALCOHOL, + reusesSlides = REUSES_SLIDES + ) - assertEquals(microscopeQuality.isDamaged, microscopeQualityDTO.isDamaged) - assertEquals(microscopeQuality.magnification, microscopeQualityDTO.magnification) - } + private val microscopeQualityDto = MicroscopeQualityDto( + isDamaged = IS_DAMAGED, + magnification = MAGNIFICATION + ) - @Test - fun `should map SampleMetadata model to DTO`() { - val metadataDTO = metadata.toDto() + private val metadataDto = SampleMetadataDto( + bloodType = smearType.id, + species = species.id, + comments = COMMENTS + ) - assertEquals(metadata.smearType.id, metadataDTO.bloodType) - assertEquals(metadata.species.id, metadataDTO.species) - assertEquals(metadata.comments, metadataDTO.comments) + private val sampleDto = SampleDto( + id = ID, + healthFacility = HEALTH_FACILITY, + microscopist = MICROSCOPIST, + disease = DISEASE, + preparation = samplePreparationDto, + microscopeQuality = microscopeQualityDto, + imagePaths = imagesList, + maskPaths = masksList, + areMasksEmpty = areMasksEmpty, + metadata = metadataDto, + status = status.id, + createdOn = createdOn, + lastModified = lastModified + ) } } diff --git a/app/src/test/java/net/aiscope/gdd_app/repository/SampleRepositorySharedPreferenceTest.kt b/app/src/test/java/net/aiscope/gdd_app/repository/SampleRepositorySharedPreferenceTest.kt index ccb40a5e..6a31eda7 100644 --- a/app/src/test/java/net/aiscope/gdd_app/repository/SampleRepositorySharedPreferenceTest.kt +++ b/app/src/test/java/net/aiscope/gdd_app/repository/SampleRepositorySharedPreferenceTest.kt @@ -11,6 +11,8 @@ import net.aiscope.gdd_app.CoroutineTestRule import net.aiscope.gdd_app.model.HealthFacility import net.aiscope.gdd_app.model.Sample import net.aiscope.gdd_app.model.SampleStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -25,6 +27,17 @@ import java.util.Calendar @RunWith(MockitoJUnitRunner::class) class SampleRepositorySharedPreferenceTest { + companion object { + private const val ID = "1111" + private const val HOSPITAL_NAME = "H. St. Pau" + private const val HOSPITAL_ID = "H_St_Pau" + private const val MICROSCOPIST = "a microscopist" + private const val DISEASE = "malaria" + + private val sampleOnlyRequired = Sample(ID, HOSPITAL_ID, MICROSCOPIST, DISEASE) + private const val sampleOnlyRequiredJson = """{"id":"1111","healthFacility":"H_St_Pau","status":"Incomplete","disease":"malaria""" + } + @get:Rule val coroutinesTestRule = CoroutineTestRule() @@ -43,15 +56,6 @@ class SampleRepositorySharedPreferenceTest { @InjectMocks lateinit var subject: SampleRepositorySharedPreference - private val ID = "1111" - private val HOSPITAL_NAME = "H. St. Pau" - private val HOSPITAL_ID = "H_St_Pau" - private val MICROSCOPIST = "a microscopist" - private val DISEASE = "malaria" - - private val sampleOnlyRequired = Sample(ID, HOSPITAL_ID, MICROSCOPIST, DISEASE) - private val sampleOnlyRequiredJson = """{"id":"1111","healthFacility":"H_St_Pau","status":"Incomplete","disease":"malaria""" - @Before fun before() = coroutinesTestRule.runBlockingTest { whenever(healthFacilityRepository.load()).thenReturn(HealthFacility(HOSPITAL_NAME, HOSPITAL_ID, MICROSCOPIST)) @@ -68,11 +72,11 @@ class SampleRepositorySharedPreferenceTest { argumentCaptor().apply { verify(store).store(eq(ID), capture()) - assert(firstValue == sampleOnlyRequiredJson) + assertEquals(sampleOnlyRequiredJson, firstValue) } - assert(stored.lastModified.after(beforeCreate)) - assert(stored.lastModified.after(stored.createdOn)) + assertTrue(stored.lastModified.after(beforeCreate)) + assertTrue(stored.lastModified.after(stored.createdOn)) } @Test @@ -81,7 +85,7 @@ class SampleRepositorySharedPreferenceTest { val sample = subject.load(ID) - assert(sample == sampleOnlyRequired) + assertEquals(sampleOnlyRequired, sample) } @Test @@ -97,13 +101,13 @@ class SampleRepositorySharedPreferenceTest { val afterCreate = Calendar.getInstance() afterCreate.add(Calendar.SECOND, 1) - assert(sample.healthFacility == HOSPITAL_ID) - assert(sample.disease == DISEASE) - assert(sample.id == uuid) - assert(sample.createdOn.after(beforeCreate)) - assert(sample.createdOn.before(afterCreate)) - assert(sample.lastModified.after(beforeCreate)) - assert(sample.lastModified.before(afterCreate)) + assertEquals(HOSPITAL_ID, sample.healthFacility) + assertEquals(DISEASE, sample.disease) + assertEquals(uuid, sample.id) + assertTrue(sample.createdOn.after(beforeCreate)) + assertTrue(sample.createdOn.before(afterCreate)) + assertTrue(sample.lastModified.after(beforeCreate)) + assertTrue(sample.lastModified.before(afterCreate)) } @Test @@ -115,7 +119,7 @@ class SampleRepositorySharedPreferenceTest { subject.create(DISEASE) val sample = subject.current() - assert(sample.id == uuid) + assertEquals(sample.id, uuid) } @Test @@ -125,7 +129,7 @@ class SampleRepositorySharedPreferenceTest { subject.load(ID) val sample = subject.current() - assert(sample == sampleOnlyRequired) + assertEquals(sampleOnlyRequired, sample) } @Test @@ -134,7 +138,7 @@ class SampleRepositorySharedPreferenceTest { val samples = subject.all() - assert(samples.size == 2) + assertEquals(2, samples.size) } @Test @@ -149,7 +153,7 @@ class SampleRepositorySharedPreferenceTest { val sample = subject.lastSaved() - assert(sample == todaySample) + assertEquals(todaySample, sample) } @Test @@ -166,7 +170,7 @@ class SampleRepositorySharedPreferenceTest { val sample = subject.current() - assert(sample == yesterdaySample) + assertEquals(yesterdaySample, sample) } private fun mockSampleAndJson(id: String, date: Calendar, status: SampleStatus): Pair {