Skip to content

Commit

Permalink
Fix photo submitting crash (#988)
Browse files Browse the repository at this point in the history
* Run CQL in parallel while in background thread

* Improve photo loading

* Add error validation

* Add styling

* Refactor photo capture widget

* Fix Failing test

* spotlessApply
  • Loading branch information
FikriMilano authored Jan 28, 2022
1 parent c6ef34a commit 2bb90c0
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 86 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ package org.smartregister.fhircore.engine.ui.questionnaire

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Environment
import android.view.View
import android.widget.ImageView
import android.widget.TextView
Expand All @@ -30,16 +27,14 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import com.google.android.fhir.datacapture.validation.ValidationResult
import com.google.android.fhir.datacapture.validation.getSingleStringValidationMessage
import com.google.android.fhir.datacapture.views.QuestionnaireItemViewHolderDelegate
import com.google.android.fhir.datacapture.views.QuestionnaireItemViewHolderFactory
import com.google.android.fhir.datacapture.views.QuestionnaireItemViewItem
import com.google.android.material.button.MaterialButton
import id.zelory.compressor.Compressor.compress
import id.zelory.compressor.constraint.quality
import id.zelory.compressor.constraint.size
import id.zelory.compressor.extension
import java.io.File
import kotlinx.coroutines.launch
import org.hl7.fhir.r4.model.Attachment
import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent
Expand All @@ -51,7 +46,6 @@ import org.smartregister.fhircore.engine.util.extension.decodeToBitmap
import org.smartregister.fhircore.engine.util.extension.encodeToByteArray
import org.smartregister.fhircore.engine.util.extension.hide
import org.smartregister.fhircore.engine.util.extension.show
import org.smartregister.fhircore.engine.util.extension.toUri

class CustomPhotoCaptureFactory(
val fragment: Fragment,
Expand All @@ -63,32 +57,24 @@ class CustomPhotoCaptureFactory(
lateinit var tvHeader: TextView
lateinit var ivThumbnail: ImageView
lateinit var btnTakePhoto: MaterialButton
lateinit var imageFile: File
lateinit var tvError: TextView
var context: Context = fragment.requireContext()
var cameraLauncher: ActivityResultLauncher<Uri>
var questionnaireResponse: QuestionnaireResponseItemAnswerComponent
var cameraLauncher: ActivityResultLauncher<Void>
var answers: MutableList<QuestionnaireResponseItemAnswerComponent> = mutableListOf()
lateinit var onAnswerChanged: () -> Unit

init {
cameraLauncher = registerCameraLauncher()
questionnaireResponse = QuestionnaireResponseItemAnswerComponent()
}

internal fun registerCameraLauncher(): ActivityResultLauncher<Uri> {
return fragment.registerForActivityResult(ActivityResultContracts.TakePicture()) { result ->
if (result) {
lifecycleScope
.launch(dispatcher.io()) {
imageFile =
compress(context, imageFile) {
quality(30)
size(64_000)
}
val imageBitmap = BitmapFactory.decodeFile(imageFile.absolutePath)
loadThumbnail(imageBitmap)
val imageBytes = imageBitmap.encodeToByteArray()
populateQuestionnaireResponse(imageBytes)
}
.invokeOnCompletion { imageFile.delete() }
internal fun registerCameraLauncher(): ActivityResultLauncher<Void> {
return fragment.registerForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap
->
if (bitmap != null) {
loadThumbnail(bitmap)
val bytes = bitmap.encodeToByteArray()
populateQuestionnaireResponse(bytes)
onAnswerChanged.invoke()
}
}
}
Expand All @@ -98,31 +84,31 @@ class CustomPhotoCaptureFactory(
) {
lifecycleScope.launch(dispatcher.main()) {
ivThumbnail.apply {
Glide.with(fragment).load(imageBitmap).into(this)
Glide.with(fragment)
.load(imageBitmap)
.apply(RequestOptions().format(DecodeFormat.PREFER_ARGB_8888))
.into(this)
show()
}
btnTakePhoto.text = fragment.getString(R.string.replace_photo)
}
}

internal fun populateQuestionnaireResponse(imageBytes: ByteArray) {
questionnaireResponse.value =
Attachment().apply {
contentType = CONTENT_TYPE
data = imageBytes
answers.clear()
val answer =
QuestionnaireResponseItemAnswerComponent().apply {
value =
Attachment().apply {
contentType = CONTENT_TYPE
data = imageBytes
}
}
}

internal fun createImageFile(): File {
return File.createTempFile(
PREFIX_BITMAP,
".${Bitmap.CompressFormat.JPEG.extension()}",
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
)
answers.add(answer)
}

internal fun launchCamera() {
imageFile.toUri(context, AUTHORITY_FILE_PROVIDER).also { uri -> cameraLauncher.launch(uri) }
cameraLauncher.launch(null)
}

override fun getQuestionnaireItemViewHolderDelegate(): QuestionnaireItemViewHolderDelegate =
Expand All @@ -136,7 +122,9 @@ class CustomPhotoCaptureFactory(
tvHeader = binding.tvHeader
ivThumbnail = binding.ivThumbnail
btnTakePhoto = binding.btnTakePhoto
tvError = binding.tvError as TextView
}
onAnswerChanged = { onAnswerChanged(context) }
}

override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) {
Expand All @@ -149,32 +137,33 @@ class CustomPhotoCaptureFactory(
tvPrefix.hide(true)
}
tvHeader.text = questionnaireItemViewItem.questionnaireItem.text
btnTakePhoto.setOnClickListener {
imageFile = createImageFile()
launchCamera()
btnTakePhoto.setOnClickListener { launchCamera() }
questionnaireItemViewItem.singleAnswerOrNull?.valueAttachment?.let { attachment ->
loadThumbnail(attachment.data.decodeToBitmap())
answers.clear()
answers.add(QuestionnaireResponseItemAnswerComponent().apply { value = attachment })
}
if (!questionnaireItemViewItem.questionnaireItem.readOnly) {
questionnaireItemViewItem.questionnaireResponseItem.answer = answers
}
questionnaireItemViewItem.singleAnswerOrNull?.valueAttachment?.data?.decodeToBitmap()
?.let { imageBitmap -> loadThumbnail(imageBitmap) }
setReadOnly(questionnaireItemViewItem.questionnaireItem.readOnly)
questionnaireItemViewItem.singleAnswerOrNull = questionnaireResponse
}

override fun displayValidationResult(validationResult: ValidationResult) {
// Custom validation message
tvError.text =
if (validationResult.getSingleStringValidationMessage() == "") null
else validationResult.getSingleStringValidationMessage()
}

// TODO -> Should use the overridden setReadOnly()
// after upgrading Data Capture library to Beta

override fun setReadOnly(isReadOnly: Boolean) {
ivThumbnail.isEnabled = !isReadOnly
btnTakePhoto.isEnabled = !isReadOnly
btnTakePhoto.apply {
isEnabled = !isReadOnly
alpha = if (isReadOnly) 0.6F else 1F
}
}
}

companion object {
private const val AUTHORITY_FILE_PROVIDER = "org.smartregister.fhircore.fileprovider"
const val CONTENT_TYPE = "image/jpg"
private const val PREFIX_BITMAP = "BITMAP_"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import java.util.Date
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.hl7.fhir.r4.context.IWorkerContext
Expand Down Expand Up @@ -193,14 +194,25 @@ constructor(
editQuestionnaireResponse!!.deleteRelatedResources(defaultRepository)
}

questionnaire.cqfLibraryIds().forEach {
val patient =
if (questionnaireResponse.hasSubject())
loadPatient(questionnaireResponse.subject.extractId())
else null
val output = libraryEvaluator.runCqlLibrary(it, patient, bundle, defaultRepository)
if (output.isNotEmpty()) extractionProgressMessage.postValue(output.joinToString("\n"))
}
questionnaire
.cqfLibraryIds()
.map {
val patient =
if (questionnaireResponse.hasSubject())
loadPatient(questionnaireResponse.subject.extractId())
else null
async(Dispatchers.Default) {
libraryEvaluator.runCqlLibrary(it, patient, bundle, defaultRepository)
}
}
.map { jobOutput ->
jobOutput.join()
jobOutput
}
.forEach { output ->
if (output.await().isNotEmpty())
extractionProgressMessage.postValue(output.await().joinToString("\n"))
}
} else {
saveQuestionnaireResponse(questionnaire, questionnaireResponse)
}
Expand Down
14 changes: 10 additions & 4 deletions android/engine/src/main/res/layout/custom_photo_capture_layout.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@
android:orientation="horizontal">

<TextView
style="?attr/headerTextAppearanceQuestionnaire"
android:id="@+id/tv_prefix"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingEnd="@dimen/prefix_padding_end"
android:visibility="gone" />

<TextView
style="?attr/headerTextAppearanceQuestionnaire"
android:id="@+id/tv_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/item_margin_vertical"
android:layout_marginEnd="@dimen/item_margin_horizontal" />
android:layout_height="wrap_content" />

</LinearLayout>

Expand All @@ -38,8 +39,8 @@
android:visibility="gone" />

<com.google.android.material.button.MaterialButton
android:id="@+id/btn_take_photo"
style="@style/Widget.AppCompat.Button.Borderless"
android:id="@+id/btn_take_photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0"
Expand All @@ -50,4 +51,9 @@
app:iconPadding="@dimen/item_margin_horizontal"
app:iconTint="@color/colorPrimaryLight" />

<include
android:id="@+id/tv_error"
layout="@layout/input_error_text_view"
/>

</LinearLayout>
Loading

0 comments on commit 2bb90c0

Please sign in to comment.