diff --git a/catalog/src/main/assets/calculated_expression_questionnaire.json b/catalog/src/main/assets/calculated_expression_questionnaire.json
new file mode 100644
index 0000000000..3097ed0701
--- /dev/null
+++ b/catalog/src/main/assets/calculated_expression_questionnaire.json
@@ -0,0 +1,36 @@
+{
+ "resourceType": "Questionnaire",
+ "item": [
+ {
+ "linkId": "a-birthdate",
+ "text": "Birth Date",
+ "type": "date",
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression",
+ "valueExpression": {
+ "language": "text/fhirpath",
+ "expression": "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)"
+ }
+ }
+ ]
+ },
+ {
+ "linkId": "a-age-years",
+ "text": "Age years",
+ "type": "quantity",
+ "initial": [{
+ "valueQuantity": {
+ "unit": "years",
+ "system": "http://unitsofmeasure.org",
+ "code": "years"
+ }
+ }]
+ },
+ {
+ "linkId": "a-age-acknowledge",
+ "text": "Input age to automatically calculate birthdate until birthdate is updated manually",
+ "type": "display"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt
index a440fa9a16..49ed891d32 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt
@@ -109,5 +109,10 @@ class ComponentListViewModel(application: Application, private val state: SavedS
"component_auto_complete.json",
"component_auto_complete_with_validation.json"
),
+ CALCULATED_EXPRESSION(
+ R.drawable.ic_unitoptions,
+ R.string.component_name_calculated_expression,
+ "calculated_expression_questionnaire.json"
+ ),
}
}
diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml
index b4f54482e3..3129ce1d30 100644
--- a/catalog/src/main/res/values/strings.xml
+++ b/catalog/src/main/res/values/strings.xml
@@ -30,6 +30,9 @@
Slider
Dropdown
Image
+ Calculated Expression
Auto Complete
Default
Paginated
diff --git a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/QuestionnaireItemBarCodeReaderViewHolderFactory.kt b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/QuestionnaireItemBarCodeReaderViewHolderFactory.kt
index c708cc30d2..f368d9755e 100644
--- a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/QuestionnaireItemBarCodeReaderViewHolderFactory.kt
+++ b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/QuestionnaireItemBarCodeReaderViewHolderFactory.kt
@@ -22,13 +22,13 @@ import android.view.View
import android.widget.TextView
import androidx.fragment.app.FragmentResultListener
import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.LiveBarcodeScanningFragment
+import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.Utils.tryUnwrapContext
import com.google.android.fhir.datacapture.localizedPrefixSpanned
import com.google.android.fhir.datacapture.localizedTextSpanned
import com.google.android.fhir.datacapture.validation.ValidationResult
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.fhir.datacapture.views.tryUnwrapContext
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.StringType
diff --git a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/Utils.kt b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/Utils.kt
index 0c54370319..e4a524c3cc 100644
--- a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/Utils.kt
+++ b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/Utils.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -32,6 +32,8 @@ import android.graphics.RectF
import android.graphics.YuvImage
import android.hardware.Camera
import android.net.Uri
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ContextThemeWrapper
import androidx.exifinterface.media.ExifInterface
import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.camera.CameraSizePair
import com.google.mlkit.vision.barcode.BarcodeScanning
@@ -224,4 +226,25 @@ object Utils {
}
fun getBarcodeScanningClient() = BarcodeScanning.getClient()
+
+ /**
+ * Returns the [AppCompatActivity] if there exists one wrapped inside [ContextThemeWrapper] s, or
+ * `null` otherwise.
+ *
+ * This function is inspired by the function with the same name in `AppCompateDelegateImpl`. See
+ * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=1615
+ *
+ * TODO: find a more robust way to do this as it is not guaranteed that the activity is an
+ * AppCompatActivity.
+ */
+ internal fun Context.tryUnwrapContext(): AppCompatActivity? {
+ var context = this
+ while (true) {
+ when (context) {
+ is AppCompatActivity -> return context
+ is ContextThemeWrapper -> context = context.baseContext
+ else -> return null
+ }
+ }
+ }
}
diff --git a/datacapture/src/androidTest/AndroidManifest.xml b/datacapture/src/androidTest/AndroidManifest.xml
index 4d2f89ab21..9d354ab04b 100644
--- a/datacapture/src/androidTest/AndroidManifest.xml
+++ b/datacapture/src/androidTest/AndroidManifest.xml
@@ -21,6 +21,7 @@
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/DataCaptureTestApplication.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/DataCaptureTestApplication.kt
new file mode 100644
index 0000000000..d45ec16ed3
--- /dev/null
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/DataCaptureTestApplication.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture
+
+import android.app.Application
+
+/** Application class when you want to test the DataCaptureConfig.Provider */
+internal class DataCaptureTestApplication : Application(), DataCaptureConfig.Provider {
+ var dataCaptureConfiguration: DataCaptureConfig? = null
+
+ override fun getDataCaptureConfig(): DataCaptureConfig {
+ if (dataCaptureConfiguration == null) {
+ dataCaptureConfiguration = DataCaptureConfig()
+ }
+
+ return dataCaptureConfiguration!!
+ }
+}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsInstrumentedTest.kt
new file mode 100644
index 0000000000..b770e97863
--- /dev/null
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsInstrumentedTest.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Base64
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import java.nio.charset.Charset
+import kotlinx.coroutines.runBlocking
+import org.hl7.fhir.r4.model.Attachment
+import org.hl7.fhir.r4.model.Binary
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class MoreQuestionnaireItemComponentsInstrumentedTest {
+
+ @Test
+ fun fetchBitmap_shouldReturnNull_whenAttachmentHasDataAndIncorrectContentType() {
+ val attachment =
+ Attachment().apply {
+ data = "some-byte".toByteArray(Charset.forName("UTF-8"))
+ contentType = "document/pdf"
+ }
+ val bitmap: Bitmap?
+ runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) }
+ assertThat(bitmap).isNull()
+ }
+
+ @Test
+ fun fetchBitmap_shouldReturnBitmap_whenAttachmentHasDataAndCorrectContentType() {
+ val attachment =
+ Attachment().apply {
+ data =
+ Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT)
+ contentType = "image/png"
+ }
+ val bitmap: Bitmap?
+ runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) }
+ assertThat(bitmap).isNotNull()
+ }
+
+ @Test
+ fun isImage_shouldTrue_whenAttachmentContentTypeIsImage() {
+ val attachment = Attachment().apply { contentType = "image/png" }
+ assertThat(attachment.isImage).isTrue()
+ }
+
+ @Test
+ fun isImage_shouldFalseWhenAttachmentContentTypeIsNotImage() {
+ val attachment = Attachment().apply { contentType = "document/pdf" }
+ assertThat(attachment.isImage).isFalse()
+ }
+
+ @Test
+ fun fetchBitmap_shouldReturnBitmapAndCallAttachmentResolverResolveBinaryResource() {
+ val attachment = Attachment().apply { url = "https://hapi.fhir.org/Binary/f006" }
+ ApplicationProvider.getApplicationContext()
+ .getDataCaptureConfig()
+ .attachmentResolver = TestAttachmentResolver()
+
+ val bitmap: Bitmap?
+ runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) }
+
+ assertThat(bitmap).isNotNull()
+ }
+
+ @Test
+ fun fetchBitmap_shouldReturnBitmapAndCallAttachmentResolverResolveImageUrl() {
+ val attachment = Attachment().apply { url = "https://some-image-server.com/images/f0006.png" }
+
+ val byteArray =
+ Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT)
+ val expectedBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
+
+ ApplicationProvider.getApplicationContext()
+ .getDataCaptureConfig()
+ .attachmentResolver = TestAttachmentResolver(expectedBitmap)
+
+ val resolvedBitmap: Bitmap?
+ runBlocking {
+ resolvedBitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext())
+ }
+
+ assertThat(resolvedBitmap).isEqualTo(expectedBitmap)
+ }
+
+ class TestAttachmentResolver(var testBitmap: Bitmap? = null) : AttachmentResolver {
+
+ override suspend fun resolveBinaryResource(uri: String): Binary? {
+ return if (uri == "https://hapi.fhir.org/Binary/f006") {
+ Binary().apply {
+ contentType = "image/png"
+ data =
+ Base64.decode(
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
+ Base64.DEFAULT
+ )
+ }
+ } else null
+ }
+
+ override suspend fun resolveImageUrl(uri: String): Bitmap? {
+ return if (uri == "https://some-image-server.com/images/f0006.png") {
+ testBitmap
+ } else null
+ }
+ }
+}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt
new file mode 100644
index 0000000000..6dfda5dc2e
--- /dev/null
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.views
+
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.typeText
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.TestActivity
+import com.google.common.truth.Truth.assertThat
+import java.math.BigDecimal
+import org.hl7.fhir.r4.model.Quantity
+import org.hl7.fhir.r4.model.Questionnaire
+import org.hl7.fhir.r4.model.QuestionnaireResponse
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest {
+ @Rule
+ @JvmField
+ var activityScenarioRule: ActivityScenarioRule =
+ ActivityScenarioRule(TestActivity::class.java)
+
+ private lateinit var parent: FrameLayout
+ private lateinit var viewHolder: QuestionnaireItemViewHolder
+
+ @Before
+ fun setup() {
+ activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) }
+ viewHolder = QuestionnaireItemEditTextQuantityViewHolderFactory.create(parent)
+ setTestLayout(viewHolder.itemView)
+ }
+
+ @Test
+ fun getValue_WithInitial_shouldReturnQuantityWithUnitAndSystem() {
+ val questionnaireItemViewItem =
+ QuestionnaireItemViewItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ required = true
+ addInitial(
+ Questionnaire.QuestionnaireItemInitialComponent(
+ Quantity().apply {
+ code = "months"
+ system = "http://unitofmeasure.com"
+ }
+ )
+ )
+ },
+ QuestionnaireResponse.QuestionnaireResponseItemComponent(),
+ validationResult = null,
+ answersChangedCallback = { _, _, _ -> },
+ )
+ runOnUI { viewHolder.bind(questionnaireItemViewItem) }
+
+ onView(withId(R.id.text_input_edit_text)).perform(click())
+ onView(withId(R.id.text_input_edit_text)).perform(typeText("22"))
+ assertThat(
+ viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString()
+ )
+ .isEqualTo("22")
+
+ val responseValue = questionnaireItemViewItem.answers.first().valueQuantity
+ assertThat(responseValue.code).isEqualTo("months")
+ assertThat(responseValue.system).isEqualTo("http://unitofmeasure.com")
+ assertThat(responseValue.value).isEqualTo(BigDecimal(22))
+ }
+
+ @Test
+ fun getValue_WithoutInitial_shouldReturnQuantityWithoutUnitAndSystem() {
+ val questionnaireItemViewItem =
+ QuestionnaireItemViewItem(
+ Questionnaire.QuestionnaireItemComponent().apply { required = true },
+ QuestionnaireResponse.QuestionnaireResponseItemComponent(),
+ validationResult = null,
+ answersChangedCallback = { _, _, _ -> },
+ )
+ runOnUI { viewHolder.bind(questionnaireItemViewItem) }
+
+ onView(withId(R.id.text_input_edit_text)).perform(click())
+ onView(withId(R.id.text_input_edit_text)).perform(typeText("22"))
+ assertThat(
+ viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString()
+ )
+ .isEqualTo("22")
+
+ val responseValue = questionnaireItemViewItem.answers.first().valueQuantity
+ assertThat(responseValue.code).isNull()
+ assertThat(responseValue.system).isNull()
+ assertThat(responseValue.value).isEqualTo(BigDecimal(22))
+ }
+
+ /** Method to run code snippet on UI/main thread */
+ private fun runOnUI(action: () -> Unit) {
+ activityScenarioRule.scenario.onActivity { action() }
+ }
+
+ /** Method to set content view for test activity */
+ private fun setTestLayout(view: View) {
+ activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) }
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+ }
+}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaViewInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaViewInstrumentedTest.kt
new file mode 100644
index 0000000000..52508725a5
--- /dev/null
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaViewInstrumentedTest.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.views
+
+import android.util.Base64
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.ImageView
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.fhir.datacapture.EXTENSION_ITEM_MEDIA
+import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.TestActivity
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.hl7.fhir.r4.model.Attachment
+import org.hl7.fhir.r4.model.Extension
+import org.hl7.fhir.r4.model.Questionnaire
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class QuestionnaireItemMediaViewInstrumentedTest {
+
+ @Rule
+ @JvmField
+ var activityScenarioRule: ActivityScenarioRule =
+ ActivityScenarioRule(TestActivity::class.java)
+
+ private lateinit var parent: FrameLayout
+ private lateinit var view: QuestionnaireItemMediaView
+
+ @Before
+ fun setUp() {
+ activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) }
+ view = QuestionnaireItemMediaView(parent.context, null)
+ setTestLayout(view)
+ }
+
+ @Test
+ fun shouldShowImage_whenItemMediaExtensionIsSet_withImageContentType() = runBlocking {
+ val attachment =
+ Attachment().apply {
+ data =
+ Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT)
+ contentType = "image/png"
+ }
+ val questionnaireItemComponent =
+ Questionnaire.QuestionnaireItemComponent().apply {
+ text = "Kindly collect the reading as shown below in the figure"
+ extension = listOf(Extension(EXTENSION_ITEM_MEDIA, attachment))
+ }
+
+ runOnUI { view.bind(questionnaireItemComponent) }
+
+ delay(1000)
+
+ assertThat(view.findViewById(R.id.item_image).visibility).isEqualTo(View.VISIBLE)
+ }
+
+ @Test
+ fun shouldHideImageView_whenItemMediaExtensionIsSet_withNonImageContentType() = runBlocking {
+ val attachment =
+ Attachment().apply {
+ data =
+ Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT)
+ contentType = "document/pdf"
+ }
+ val questionnaireItemComponent =
+ Questionnaire.QuestionnaireItemComponent().apply {
+ text = "Kindly collect the reading as shown below in the figure"
+ extension = listOf(Extension(EXTENSION_ITEM_MEDIA, attachment))
+ }
+
+ runOnUI { view.bind(questionnaireItemComponent) }
+
+ delay(1000)
+
+ assertThat(view.findViewById(R.id.item_image).visibility).isEqualTo(View.GONE)
+ }
+
+ @Test
+ fun shouldHideImageView_whenItemMediaExtensionIsNotSet() = runBlocking {
+ val questionnaireItemComponent =
+ Questionnaire.QuestionnaireItemComponent().apply {
+ text = "Kindly collect the reading as shown below in the figure"
+ }
+
+ runOnUI { view.bind(questionnaireItemComponent) }
+
+ delay(1000)
+
+ assertThat(view.findViewById(R.id.item_image).visibility).isEqualTo(View.GONE)
+ }
+
+ /** Method to run code snippet on UI/main thread */
+ private fun runOnUI(action: () -> Unit) {
+ activityScenarioRule.scenario.onActivity { action() }
+ }
+
+ /** Method to set content view for test activity */
+ private fun setTestLayout(view: View) {
+ activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) }
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+ }
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt
index 4b7bf17781..ef8222d564 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt
@@ -17,8 +17,10 @@
package com.google.android.fhir.datacapture
import android.app.Application
+import android.graphics.Bitmap
import com.google.android.fhir.datacapture.DataCaptureConfig.Provider
import org.hl7.fhir.r4.context.SimpleWorkerContext
+import org.hl7.fhir.r4.model.Binary
import org.hl7.fhir.r4.model.Coding
import org.hl7.fhir.r4.model.StructureMap
import org.hl7.fhir.utilities.npm.NpmPackage
@@ -45,7 +47,13 @@ data class DataCaptureConfig(
* should try to include the smallest [NpmPackage] possible that contains only the resources
* needed by [StructureMap]s used by the client app.
*/
- var npmPackage: NpmPackage? = null
+ var npmPackage: NpmPackage? = null,
+
+ /**
+ * Used to resolve/process [org.hl7.fhir.r4.model.Attachment] to it's binary equivalent. The
+ * attachment's can either have the data or a URL
+ */
+ var attachmentResolver: AttachmentResolver? = null
) {
internal val simpleWorkerContext: SimpleWorkerContext by lazy {
@@ -75,3 +83,10 @@ data class DataCaptureConfig(
interface ExternalAnswerValueSetResolver {
suspend fun resolve(uri: String): List
}
+
+interface AttachmentResolver {
+
+ suspend fun resolveBinaryResource(uri: String): Binary?
+
+ suspend fun resolveImageUrl(uri: String): Bitmap?
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponents.kt
index 6fd11c4ad0..66aa493d7b 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponents.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponents.kt
@@ -16,9 +16,15 @@
package com.google.android.fhir.datacapture
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.text.Spanned
+import android.util.Base64
import androidx.core.text.HtmlCompat
import com.google.android.fhir.getLocalizedText
+import org.hl7.fhir.r4.model.Attachment
+import org.hl7.fhir.r4.model.Binary
import org.hl7.fhir.r4.model.BooleanType
import org.hl7.fhir.r4.model.CodeType
import org.hl7.fhir.r4.model.CodeableConcept
@@ -26,6 +32,7 @@ import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.StringType
+import timber.log.Timber
/** UI controls relevant to capturing question data. */
internal enum class ItemControlTypes(
@@ -55,6 +62,11 @@ internal const val EXTENSION_ITEM_CONTROL_SYSTEM = "http://hl7.org/fhir/question
internal const val EXTENSION_HIDDEN_URL =
"http://hl7.org/fhir/StructureDefinition/questionnaire-hidden"
+internal const val EXTENSION_ITEM_MEDIA =
+ "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemMedia"
+
+internal const val EXTENSION_CALCULATED_EXPRESSION_URL =
+ "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression"
internal const val EXTENSION_ENTRY_FORMAT_URL =
"http://hl7.org/fhir/StructureDefinition/entryFormat"
@@ -69,6 +81,9 @@ internal const val EXTENSION_ENABLE_WHEN_EXPRESSION_URL: String =
internal const val EXTENSION_VARIABLE_URL = "http://hl7.org/fhir/StructureDefinition/variable"
+internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String =
+ "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue"
+
internal val Questionnaire.QuestionnaireItemComponent.variableExpressions: List
get() =
this.extension.filter { it.url == EXTENSION_VARIABLE_URL }.map { it.castToExpression(it.value) }
@@ -87,8 +102,29 @@ internal fun Questionnaire.QuestionnaireItemComponent.findVariableExpression(
return variableExpressions.find { it.name == variableName }
}
-internal const val CQF_CALCULATED_EXPRESSION_URL: String =
- "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue"
+/** Returns Calculated expression, or null */
+internal val Questionnaire.QuestionnaireItemComponent.calculatedExpression: Expression?
+ get() =
+ this.getExtensionByUrl(EXTENSION_CALCULATED_EXPRESSION_URL)?.let {
+ it.castToExpression(it.value)
+ }
+
+/** Returns list of extensions whose value is of type [Expression] */
+internal val Questionnaire.QuestionnaireItemComponent.expressionBasedExtensions
+ get() = this.extension.filter { it.value is Expression }
+
+/**
+ * Whether [item] has any expression directly referencing the current questionnaire item by link ID (e.g. if [item] has an expression `%resource.item.where(linkId='this-question')` where `this-question` is the link ID of the current questionnaire item).
+ */
+internal fun Questionnaire.QuestionnaireItemComponent.isReferencedBy(
+ item: Questionnaire.QuestionnaireItemComponent
+) =
+ item.expressionBasedExtensions.any {
+ it.castToExpression(it.value)
+ .expression
+ .replace(" ", "")
+ .contains(Regex(".*linkId='${this.linkId}'.*"))
+ }
// Item control code, or null
internal val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTypes?
@@ -104,7 +140,7 @@ internal val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTy
?.coding
?.firstOrNull {
it.system == EXTENSION_ITEM_CONTROL_SYSTEM ||
- it.system == EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR
+ it.system == EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR
}
?.code
return ItemControlTypes.values().firstOrNull { it.extensionCode == code }
@@ -123,7 +159,7 @@ internal val Questionnaire.QuestionnaireItemComponent.choiceOrientation: ChoiceO
get() {
val code =
(this.extension.firstOrNull { it.url == EXTENSION_CHOICE_ORIENTATION_URL }?.value
- as CodeType?)
+ as CodeType?)
?.valueAsString
return ChoiceOrientationTypes.values().firstOrNull { it.extensionCode == code }
}
@@ -199,7 +235,7 @@ internal val Questionnaire.QuestionnaireItemComponent.localizedInstructionsSpann
return item
.firstOrNull { questionnaireItem ->
questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY &&
- questionnaireItem.isInstructionsCode
+ questionnaireItem.isInstructionsCode
}
?.localizedTextSpanned
}
@@ -213,7 +249,7 @@ internal val Questionnaire.QuestionnaireItemComponent.localizedFlyoverSpanned: S
item
.firstOrNull { questionnaireItem ->
questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY &&
- questionnaireItem.displayItemControl == DisplayItemControlType.FLYOVER
+ questionnaireItem.displayItemControl == DisplayItemControlType.FLYOVER
}
?.localizedTextSpanned
@@ -261,7 +297,7 @@ internal val Questionnaire.QuestionnaireItemComponent.isInstructionsCode: Boolea
Questionnaire.QuestionnaireItemType.DISPLAY -> {
val codeableConcept =
this.extension.firstOrNull { it.url == EXTENSION_DISPLAY_CATEGORY_URL }?.value
- as CodeableConcept?
+ as CodeableConcept?
val code =
codeableConcept
?.coding
@@ -299,14 +335,14 @@ internal val Questionnaire.QuestionnaireItemComponent.isFlyoverCode: Boolean
* https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details.
*/
fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItem():
- QuestionnaireResponse.QuestionnaireResponseItemComponent {
+ QuestionnaireResponse.QuestionnaireResponseItemComponent {
return QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
linkId = this@createQuestionnaireResponseItem.linkId
answer = createQuestionnaireResponseItemAnswers()
if (hasNestedItemsWithinAnswers && answer.isNotEmpty()) {
this.addNestedItemsToAnswer(this@createQuestionnaireResponseItem)
} else if (this@createQuestionnaireResponseItem.type ==
- Questionnaire.QuestionnaireItemType.GROUP
+ Questionnaire.QuestionnaireItemType.GROUP
) {
this@createQuestionnaireResponseItem.item.forEach {
this.addItem(it.createQuestionnaireResponseItem())
@@ -328,25 +364,30 @@ val Questionnaire.QuestionnaireItemComponent.enableWhenExpression: Expression?
* value.
*/
private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers():
- MutableList? {
- if (initial.isEmpty()) {
+ MutableList? {
+ // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial
+ // quantity given as initial without value is for unit reference purpose only. Answer conversion
+ // not needed
+ if (initial.isEmpty() ||
+ (initialFirstRep.hasValueQuantity() && initialFirstRep.valueQuantity.value == null)
+ ) {
return null
}
-
+
if (type == Questionnaire.QuestionnaireItemType.GROUP ||
- type == Questionnaire.QuestionnaireItemType.DISPLAY
+ type == Questionnaire.QuestionnaireItemType.DISPLAY
) {
throw IllegalArgumentException(
"Questionnaire item $linkId has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial."
)
}
-
+
if (initial.size > 1 && !repeats) {
throw IllegalArgumentException(
"Questionnaire item $linkId can only have multiple initial values for repeating items. See rule que-13 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial."
)
}
-
+
return mutableListOf(
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
value = initial[0].value
@@ -368,6 +409,15 @@ fun QuestionnaireResponse.QuestionnaireResponseItemComponent.addNestedItemsToAns
}
}
+/**
+ * Flatten a nested list of [Questionnaire.QuestionnaireItemComponent] recursively and returns a
+ * flat list of all items into list embedded at any level
+ */
+fun List.flattened():
+ List {
+ return this + this.flatMap { it.item.flattened() }
+}
+
/**
* Creates a list of [QuestionnaireResponse.QuestionnaireResponseItemComponent]s from the nested
* items in the [Questionnaire.QuestionnaireItemComponent].
@@ -377,3 +427,57 @@ fun QuestionnaireResponse.QuestionnaireResponseItemComponent.addNestedItemsToAns
*/
private inline fun Questionnaire.QuestionnaireItemComponent.getNestedQuestionnaireResponseItems() =
item.map { it.createQuestionnaireResponseItem() }
+
+/** The Attachment defined in the [EXTENSION_ITEM_MEDIA] extension where applicable */
+internal val Questionnaire.QuestionnaireItemComponent.itemMedia: Attachment?
+ get() {
+ val extension = this.extension.singleOrNull { it.url == EXTENSION_ITEM_MEDIA }
+ return if (extension != null) extension.value as Attachment else null
+ }
+
+/** Whether the Attachment has a [Attachment.contentType] for an image */
+val Attachment.isImage: Boolean
+ get() = this.hasContentType() && contentType.startsWith("image")
+
+/** Whether the Binary has a [Binary.contentType] for an image */
+fun Binary.isImage(): Boolean = this.hasContentType() && contentType.startsWith("image")
+
+/** Decodes the Bitmap from the Base64 encoded string in [Bitmap.data] */
+fun Binary.getBitmap(): Bitmap? {
+ return if (isImage()) {
+ Base64.decode(this.dataElement.valueAsString, Base64.DEFAULT).let { byteArray ->
+ BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
+ }
+ } else {
+ Timber.e("Binary does not have a contentType image")
+ null
+ }
+}
+
+/**
+ * Returns the Bitmap defined in the attachment as inline Base64 encoded image, Binary resource
+ * defined in the url or externally hosted image. Inline Base64 encoded image requires to have
+ * contentType starting with image
+ */
+suspend fun Attachment.fetchBitmap(context: Context): Bitmap? {
+ // Attachment's with data inline need the contentType property
+ // Conversion to Bitmap should only be made if the contentType is image
+ if (data != null) {
+ if (isImage) {
+ return BitmapFactory.decodeByteArray(data, 0, data.size)
+ }
+ Timber.e("Attachment of contentType ${this.contentType} is not supported")
+ return null
+ } else if (url != null && (url.startsWith("https") || url.startsWith("http"))) {
+ // Points to a Binary resource on a FHIR compliant server
+ val attachmentResolver = DataCapture.getConfiguration(context).attachmentResolver
+ return if (url.contains("/Binary/")) {
+ attachmentResolver?.run { resolveBinaryResource(url)?.getBitmap() }
+ } else {
+ attachmentResolver?.resolveImageUrl(url)
+ }
+ }
+
+ Timber.e("Could not determine the Bitmap in Attachment $id")
+ return null
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt
index 041a114c74..c77ec91ffe 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt
@@ -26,6 +26,8 @@ import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.FhirVersionEnum
import ca.uhn.fhir.parser.IParser
import com.google.android.fhir.datacapture.enablement.EnablementEvaluator
+import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency
+import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCalculatedExpressions
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.validation.QuestionnaireResponseItemValidator
import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator.checkQuestionnaireResponse
@@ -46,13 +48,13 @@ import timber.log.Timber
internal class QuestionnaireViewModel(application: Application, state: SavedStateHandle) :
AndroidViewModel(application) {
-
+
private val parser: IParser by lazy { FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() }
-
+
/** The current questionnaire as questions are being answered. */
internal val questionnaire: Questionnaire
private lateinit var currentPageItems: List
-
+
init {
questionnaire =
when {
@@ -60,7 +62,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_JSON_STRING)) {
Timber.w(
"Both EXTRA_QUESTIONNAIRE_JSON_URI & EXTRA_QUESTIONNAIRE_JSON_STRING are provided. " +
- "EXTRA_QUESTIONNAIRE_JSON_URI takes precedence."
+ "EXTRA_QUESTIONNAIRE_JSON_URI takes precedence."
)
}
val uri: Uri = state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_JSON_URI]!!
@@ -77,13 +79,13 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
)
}
}
-
+
@VisibleForTesting
val entryMode: EntryMode by lazy { questionnaire.entryMode ?: EntryMode.RANDOM }
-
+
/** The current questionnaire response as questions are being answered. */
private val questionnaireResponse: QuestionnaireResponse
-
+
/**
* True if the user has tapped the next/previous pagination buttons on the current page. This is
* needed to avoid spewing validation errors before any questions are answered.
@@ -95,13 +97,13 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING)) {
Timber.w(
"Both EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI & EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING are provided. " +
- "EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI takes precedence."
+ "EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI takes precedence."
)
}
val uri: Uri = state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI]!!
questionnaireResponse =
parser.parseResource(application.contentResolver.openInputStream(uri))
- as QuestionnaireResponse
+ as QuestionnaireResponse
checkQuestionnaireResponse(questionnaire, questionnaireResponse)
}
state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING) -> {
@@ -124,14 +126,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}
}
-
+
/**
* The pre-order traversal trace of the items in the [QuestionnaireResponse]. This essentially
* represents the order in which all items are displayed in the UI.
*/
private val questionnaireResponseItemPreOrderList =
mutableListOf()
-
+
init {
/**
* Adds all items in the [QuestionnaireResponse] to the pre-order list. Note that each
@@ -149,16 +151,16 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}
}
-
+
for (item in questionnaireResponse.item) {
buildPreOrderList(item)
}
}
-
+
/** The map from each item in the [Questionnaire] to its parent. */
private var questionnaireItemParentMap:
- Map
-
+ Map
+
init {
/** Adds each child-parent pair in the [Questionnaire] to the parent map. */
fun buildParentList(
@@ -170,21 +172,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
buildParentList(child, questionnaireItemToParentMap)
}
}
-
+
questionnaireItemParentMap = buildMap {
for (item in questionnaire.item) {
buildParentList(item, this)
}
}
}
-
+
/** The map from each item in the [QuestionnaireResponse] to its parent. */
private val questionnaireResponseItemParentMap =
mutableMapOf<
- QuestionnaireResponse.QuestionnaireResponseItemComponent,
- QuestionnaireResponse.QuestionnaireResponseItemComponent
- >()
-
+ QuestionnaireResponse.QuestionnaireResponseItemComponent,
+ QuestionnaireResponse.QuestionnaireResponseItemComponent
+ >()
+
init {
/** Adds each child-parent pair in the [QuestionnaireResponse] to the parent map. */
fun buildParentList(item: QuestionnaireResponse.QuestionnaireResponseItemComponent) {
@@ -193,45 +195,45 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
buildParentList(child)
}
}
-
+
for (item in questionnaireResponse.item) {
buildParentList(item)
}
}
-
+
/** The pages of the questionnaire, or null if the questionnaire is not paginated. */
@VisibleForTesting var pages: List? = questionnaire.getInitialPages()
-
+
/**
* The flow representing the index of the current page, or null if the questionnaire is not
* paginated.
*/
@VisibleForTesting val currentPageIndexFlow = MutableStateFlow(getInitialPageIndex())
-
+
/** Flag to support fragment for review-feature */
private val enableReviewPage: Boolean
-
+
init {
enableReviewPage = state[QuestionnaireFragment.EXTRA_ENABLE_REVIEW_PAGE] ?: false
}
-
+
/** Flag to open fragment first in data-collection or review-mode */
private val showReviewPageFirst: Boolean
-
+
init {
showReviewPageFirst =
enableReviewPage && state[QuestionnaireFragment.EXTRA_SHOW_REVIEW_PAGE_FIRST] ?: false
}
-
+
/** Tracks modifications in order to update the UI. */
private val modificationCount = MutableStateFlow(0)
-
+
/** Toggles review mode. */
private val reviewFlow = MutableStateFlow(showReviewPageFirst)
-
+
/** Flag to show/hide submit button. */
private var showSubmitButtonFlag = false
-
+
/**
* Contains [QuestionnaireResponse.QuestionnaireResponseItemComponent]s that have been modified by
* the user. [QuestionnaireResponse.QuestionnaireResponseItemComponent]s that have not been
@@ -240,7 +242,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
*/
private val modifiedQuestionnaireResponseItemSet =
mutableSetOf()
-
+
/**
* Callback function to update the view model after the answer(s) to a question have been changed.
* This is passed to the [QuestionnaireItemViewItem] in its constructor so that it can invoke this
@@ -258,25 +260,26 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
* new answers to the question.
*/
private val answersChangedCallback:
- (
- Questionnaire.QuestionnaireItemComponent,
- QuestionnaireResponse.QuestionnaireResponseItemComponent,
- List
- ) -> Unit =
+ (
+ Questionnaire.QuestionnaireItemComponent,
+ QuestionnaireResponse.QuestionnaireResponseItemComponent,
+ List) -> Unit =
{ questionnaireItem, questionnaireResponseItem, answers ->
// TODO(jingtang10): update the questionnaire response item pre-order list and the parent map
questionnaireResponseItem.answer = answers.toList()
if (questionnaireItem.hasNestedItemsWithinAnswers) {
questionnaireResponseItem.addNestedItemsToAnswer(questionnaireItem)
}
-
modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem)
+
+ runCalculations(questionnaireItem)
+
modificationCount.update { it + 1 }
}
-
+
private val answerValueSetMap =
mutableMapOf>()
-
+
/**
* Returns current [QuestionnaireResponse] captured by the UI which includes answers of enabled
* questions.
@@ -286,7 +289,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
item = getEnabledResponseItems(this@QuestionnaireViewModel.questionnaire.item, item)
}
}
-
+
internal fun goToPreviousPage() {
when (entryMode) {
EntryMode.PRIOR_EDIT,
@@ -303,7 +306,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}
}
-
+
internal fun goToNextPage() {
when (entryMode) {
EntryMode.PRIOR_EDIT,
@@ -314,7 +317,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
isPaginationButtonPressed = true
modificationCount.update { it + 1 }
}
-
+
if (currentPageItems.all { it.validationResult is Valid }) {
isPaginationButtonPressed = false
val nextPageIndex =
@@ -331,46 +334,74 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}
}
-
+
internal fun setReviewMode(reviewModeFlag: Boolean) {
reviewFlow.value = reviewModeFlag
}
-
+
internal fun setShowSubmitButtonFlag(showSubmitButton: Boolean) {
showSubmitButtonFlag = showSubmitButton
}
-
+
/** [QuestionnaireState] to be displayed in the UI. */
internal val questionnaireStateFlow: Flow =
combine(modificationCount, currentPageIndexFlow, reviewFlow) { _, pagination, reviewFlow ->
- if (reviewFlow) {
- getQuestionnaireState(
- questionnaireItemList = questionnaire.item,
- questionnaireResponseItemList = questionnaireResponse.item,
- currentPageIndex = null,
- reviewMode = reviewFlow
- )
- } else {
- getQuestionnaireState(
- questionnaireItemList = questionnaire.item,
- questionnaireResponseItemList = questionnaireResponse.item,
- currentPageIndex = pagination,
- reviewMode = reviewFlow
- )
- }
+ if (reviewFlow) {
+ getQuestionnaireState(
+ questionnaireItemList = questionnaire.item,
+ questionnaireResponseItemList = questionnaireResponse.item,
+ currentPageIndex = null,
+ reviewMode = reviewFlow
+ )
+ } else {
+ getQuestionnaireState(
+ questionnaireItemList = questionnaire.item,
+ questionnaireResponseItemList = questionnaireResponse.item,
+ currentPageIndex = pagination,
+ reviewMode = reviewFlow
+ )
}
+ }
.stateIn(
viewModelScope,
SharingStarted.Lazily,
initialValue =
- getQuestionnaireState(
- questionnaireItemList = questionnaire.item,
- questionnaireResponseItemList = questionnaireResponse.item,
- currentPageIndex = getInitialPageIndex(),
- reviewMode = enableReviewPage
- )
+ getQuestionnaireState(
+ questionnaireItemList = questionnaire.item,
+ questionnaireResponseItemList = questionnaireResponse.item,
+ currentPageIndex = getInitialPageIndex(),
+ reviewMode = enableReviewPage
+ )
+ .also { detectExpressionCyclicDependency(questionnaire.item) }
+ .also { questionnaire.item.flattened().forEach { runCalculations(it) } }
)
-
+
+ fun runCalculations(updatedQuestionnaireItem: Questionnaire.QuestionnaireItemComponent) {
+ evaluateCalculatedExpressions(
+ updatedQuestionnaireItem,
+ questionnaire,
+ questionnaireResponse,
+ modifiedQuestionnaireResponseItemSet,
+ questionnaireItemParentMap
+ )
+ .forEach { (questionnaireItem, calculatedAnswers) ->
+ // update all response item with updated values
+ questionnaireResponseItemPreOrderList
+ .filter { it.linkId == questionnaireItem.linkId }
+ .forEach { questionnaireResponseItem ->
+ // update and notify only if new answer has changed to prevent any event loop
+ if (questionnaireResponseItem.answer.hasDifferentAnswerSet(calculatedAnswers)) {
+ questionnaireResponseItem.answer =
+ calculatedAnswers.map {
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
+ value = it
+ }
+ }
+ }
+ }
+ }
+ }
+
@PublishedApi
internal suspend fun resolveAnswerValueSet(
uri: String
@@ -379,14 +410,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
if (answerValueSetMap.contains(uri)) {
return answerValueSetMap[uri]!!
}
-
+
val options =
if (uri.startsWith("#")) {
questionnaire.contained
.firstOrNull { resource ->
resource.id.equals(uri) &&
- resource.resourceType == ResourceType.ValueSet &&
- (resource as ValueSet).hasExpansion()
+ resource.resourceType == ResourceType.ValueSet &&
+ (resource as ValueSet).hasExpansion()
}
?.let { resource ->
val valueSet = resource as ValueSet
@@ -410,7 +441,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
answerValueSetMap[uri] = options
return options
}
-
+
/**
* Traverses through the list of questionnaire items, the list of questionnaire response items and
* the list of items in the questionnaire response answer list and populates
@@ -425,36 +456,36 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
currentPageIndex: Int?,
reviewMode: Boolean
): QuestionnaireState {
-
+
val showReviewButton =
enableReviewPage &&
- !reviewFlow.value &&
- (currentPageIndex == null ||
- !QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndex)
- .hasNextPage)
-
+ !reviewFlow.value &&
+ (currentPageIndex == null ||
+ !QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndex)
+ .hasNextPage)
+
val showSubmitButton =
showSubmitButtonFlag &&
- !showReviewButton &&
- (currentPageIndex == null ||
- !QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndex)
- .hasNextPage)
-
+ !showReviewButton &&
+ (currentPageIndex == null ||
+ !QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndex)
+ .hasNextPage)
+
if (currentPageIndex == null) {
// Single-page questionnaire
return QuestionnaireState(
items = getQuestionnaireItemViewItems(questionnaireItemList, questionnaireResponseItemList),
pagination =
- QuestionnairePagination(false, emptyList(), -1, showSubmitButton, showReviewButton),
+ QuestionnairePagination(false, emptyList(), -1, showSubmitButton, showReviewButton),
reviewMode = reviewMode
)
}
-
+
// Paginated questionnaire
pages =
questionnaireItemList.zip(questionnaireResponseItemList).mapIndexed {
- index,
- (questionnaireItem, questionnaireResponseItem) ->
+ index,
+ (questionnaireItem, questionnaireResponseItem) ->
QuestionnairePage(
index,
EnablementEvaluator.evaluate(
@@ -468,22 +499,22 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
return QuestionnaireState(
items =
- getQuestionnaireItemViewItems(
- questionnaireItemList[currentPageIndex],
- questionnaireResponseItemList[currentPageIndex]
- ),
+ getQuestionnaireItemViewItems(
+ questionnaireItemList[currentPageIndex],
+ questionnaireResponseItemList[currentPageIndex]
+ ),
pagination =
- QuestionnairePagination(
- true,
- pages!!,
- currentPageIndex,
- showSubmitButton,
- showReviewButton
- ),
+ QuestionnairePagination(
+ true,
+ pages!!,
+ currentPageIndex,
+ showSubmitButton,
+ showReviewButton
+ ),
reviewMode = reviewMode
)
}
-
+
/**
* Returns the list of [QuestionnaireItemViewItem]s generated for the questionnaire items and
* questionnaire response items.
@@ -500,17 +531,17 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
// If there is an enabled questionnaire response available then we use that. Or else we
// just use an empty questionnaireResponse Item
if (responseIndex < questionnaireResponseItemList.size &&
- questionnaireItem.linkId == questionnaireResponseItemList[responseIndex].linkId
+ questionnaireItem.linkId == questionnaireResponseItemList[responseIndex].linkId
) {
questionnaireResponseItem = questionnaireResponseItemList[responseIndex]
responseIndex += 1
}
-
+
getQuestionnaireItemViewItems(questionnaireItem, questionnaireResponseItem)
}
.toList()
}
-
+
/**
* Returns the list of [QuestionnaireItemViewItem]s generated for the questionnaire item and
* questionnaire response item.
@@ -527,14 +558,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
) { questionnaireResponseItem, linkId ->
findEnableWhenQuestionnaireResponseItem(questionnaireResponseItem, linkId)
}
-
+
if (!enabled || questionnaireItem.isHidden) {
return emptyList()
}
-
+
val validationResult =
if (modifiedQuestionnaireResponseItemSet.contains(questionnaireResponseItem) ||
- isPaginationButtonPressed
+ isPaginationButtonPressed
) {
QuestionnaireResponseItemValidator.validate(
questionnaireItem,
@@ -554,25 +585,25 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
resolveAnswerValueSet = { resolveAnswerValueSet(it) },
)
) +
- getQuestionnaireItemViewItems(
- // If nested display item is identified as instructions or flyover, then do not create
- // questionnaire state for it.
- questionnaireItemList =
- questionnaireItem.item.filterNot {
- it.type == Questionnaire.QuestionnaireItemType.DISPLAY &&
- (it.isInstructionsCode || it.isFlyoverCode || it.isHelpCode)
- },
- questionnaireResponseItemList =
- if (questionnaireResponseItem.answer.isEmpty()) {
- questionnaireResponseItem.item
- } else {
- questionnaireResponseItem.answer.first().item
- },
- )
+ getQuestionnaireItemViewItems(
+ // If nested display item is identified as instructions or flyover, then do not create
+ // questionnaire state for it.
+ questionnaireItemList =
+ questionnaireItem.item.filterNot {
+ it.type == Questionnaire.QuestionnaireItemType.DISPLAY &&
+ (it.isInstructionsCode || it.isFlyoverCode || it.isHelpCode)
+ },
+ questionnaireResponseItemList =
+ if (questionnaireResponseItem.answer.isEmpty()) {
+ questionnaireResponseItem.item
+ } else {
+ questionnaireResponseItem.answer.first().item
+ },
+ )
currentPageItems = items
return items
}
-
+
private fun getEnabledResponseItems(
questionnaireItemList: List,
questionnaireResponseItemList: List,
@@ -603,7 +634,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
.toList()
}
-
+
/**
* Checks if this questionnaire uses pagination via the "page" extension.
*
@@ -619,7 +650,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
} else {
null
}
-
+
private fun Questionnaire.getInitialPages() =
if (questionnaire.isPaginated) {
// Assume all pages are enabled to begin with
@@ -627,7 +658,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
} else {
null
}
-
+
/**
* Find a questionnaire response item in [QuestionnaireResponse] with the given `linkId` starting
* from the `origin`.
@@ -654,7 +685,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
parent = questionnaireResponseItemParentMap[parent]
}
-
+
// Find the nearest item preceding the origin
val itemIndex = questionnaireResponseItemPreOrderList.indexOf(origin)
for (index in itemIndex - 1 downTo 0) {
@@ -662,20 +693,20 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
return questionnaireResponseItemPreOrderList[index]
}
}
-
+
// Find the nearest item succeeding the origin
for (index in itemIndex + 1 until questionnaireResponseItemPreOrderList.size) {
if (questionnaireResponseItemPreOrderList[index].linkId == linkId) {
return questionnaireResponseItemPreOrderList[index]
}
}
-
+
return null
}
}
typealias ItemToParentMap =
- MutableMap
+ MutableMap
/** Questionnaire state for the Fragment to consume. */
internal data class QuestionnaireState(
@@ -709,4 +740,4 @@ internal val QuestionnairePagination.hasPreviousPage: Boolean
get() = pages.any { it.index < currentPageIndex && it.enabled }
internal val QuestionnairePagination.hasNextPage: Boolean
- get() = pages.any { it.index > currentPageIndex && it.enabled }
+ get() = pages.any { it.index > currentPageIndex && it.enabled }
\ No newline at end of file
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt
index 334d8e8461..aa641478c3 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt
@@ -18,7 +18,6 @@ package com.google.android.fhir.datacapture.mapping
import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.FhirVersionEnum
-import ca.uhn.fhir.context.support.DefaultProfileValidationSupport
import com.google.android.fhir.datacapture.DataCapture
import com.google.android.fhir.datacapture.createQuestionnaireResponseItem
import com.google.android.fhir.datacapture.targetStructureMap
@@ -77,7 +76,7 @@ object ResourceMapper {
private val fhirPathEngine: FHIRPathEngine =
with(FhirContext.forCached(FhirVersionEnum.R4)) {
- FHIRPathEngine(HapiWorkerContext(this, DefaultProfileValidationSupport(this)))
+ FHIRPathEngine(HapiWorkerContext(this, this.validationSupport))
}
/**
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreContext.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreContext.kt
new file mode 100644
index 0000000000..98cc2e8479
--- /dev/null
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreContext.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.utilities
+
+import android.content.Context
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ContextThemeWrapper
+
+/**
+ * Returns the [AppCompatActivity] if there exists one wrapped inside [ContextThemeWrapper] s, or
+ * `null` otherwise.
+ *
+ * This function is inspired by the function with the same name in `AppCompateDelegateImpl`. See
+ * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=1615
+ *
+ * TODO: find a more robust way to do this as it is not guaranteed that the activity is an
+ * AppCompatActivity.
+ */
+internal fun Context.tryUnwrapContext(): AppCompatActivity? {
+ var context = this
+ while (true) {
+ when (context) {
+ is AppCompatActivity -> return context
+ is ContextThemeWrapper -> context = context.baseContext
+ else -> return null
+ }
+ }
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreExpressions.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreExpressions.kt
new file mode 100644
index 0000000000..11d6e56005
--- /dev/null
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreExpressions.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.utilities
+
+import org.hl7.fhir.r4.model.Expression
+
+/**
+ * An expression which does not refer to a specific linkId or variable and derives value using a
+ * generic expression
+ */
+internal val Expression.hasDynamicExpression
+ get() =
+ this.expression.replace(" ", "").contains("linkId='").not() ||
+ this.expression.matches(Regex(".*%\\w+.*")).not()
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactory.kt
index 1b2c697116..c5e3cacd2b 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactory.kt
@@ -21,12 +21,11 @@ import android.content.Context
import android.icu.text.DateFormat
import android.text.TextWatcher
import android.view.View
-import androidx.appcompat.app.AppCompatActivity
-import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.widget.doAfterTextChanged
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.utilities.isAndroidIcuSupported
import com.google.android.fhir.datacapture.utilities.localizedString
+import com.google.android.fhir.datacapture.utilities.tryUnwrapContext
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.MaxValueConstraintValidator.getMaxValue
import com.google.android.fhir.datacapture.validation.MinValueConstraintValidator.getMinValue
@@ -73,7 +72,7 @@ internal object QuestionnaireItemDatePickerViewHolderFactory :
IsoChronology.INSTANCE,
Locale.getDefault()
)
-
+
override fun init(itemView: View) {
header = itemView.findViewById(R.id.header)
textInputLayout = itemView.findViewById(R.id.text_input_layout)
@@ -104,12 +103,13 @@ internal object QuestionnaireItemDatePickerViewHolderFactory :
.show(context.supportFragmentManager, TAG)
}
}
-
+
@SuppressLint("NewApi") // java.time APIs can be used due to desugaring
override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) {
header.bind(questionnaireItemViewItem.questionnaireItem)
textInputLayout.hint = localePattern
textInputEditText.removeTextChangedListener(textWatcher)
+
if (isTextUpdateRequired(
textInputEditText.context,
questionnaireItemViewItem.answers.singleOrNull()?.valueDateType,
@@ -117,7 +117,10 @@ internal object QuestionnaireItemDatePickerViewHolderFactory :
)
) {
textInputEditText.setText(
- questionnaireItemViewItem.answers.singleOrNull()
+ questionnaireItemViewItem
+ .answers
+ .singleOrNull()
+ ?.takeIf { it.hasValue() }
?.valueDateType
?.localDate
?.localizedString
@@ -125,7 +128,7 @@ internal object QuestionnaireItemDatePickerViewHolderFactory :
}
textWatcher = textInputEditText.doAfterTextChanged { text -> updateAnswer(text.toString()) }
}
-
+
override fun displayValidationResult(validationResult: ValidationResult) {
textInputLayout.error =
when (validationResult) {
@@ -133,12 +136,12 @@ internal object QuestionnaireItemDatePickerViewHolderFactory :
is Invalid -> validationResult.getSingleStringValidationMessage()
}
}
-
+
override fun setReadOnly(isReadOnly: Boolean) {
textInputEditText.isEnabled = !isReadOnly
textInputLayout.isEnabled = !isReadOnly
}
-
+
private fun createMaterialDatePicker(): MaterialDatePicker {
val selectedDate =
questionnaireItemViewItem
@@ -156,25 +159,25 @@ internal object QuestionnaireItemDatePickerViewHolderFactory :
.setCalendarConstraints(getCalenderConstraint())
.build()
}
-
+
private fun getCalenderConstraint(): CalendarConstraints {
val min =
(getMinValue(questionnaireItemViewItem.questionnaireItem) as? DateType)?.value?.time
val max =
(getMaxValue(questionnaireItemViewItem.questionnaireItem) as? DateType)?.value?.time
-
+
if (min != null && max != null && min > max) {
throw IllegalArgumentException("minValue cannot be greater than maxValue")
}
-
+
val listValidators = ArrayList()
min?.let { listValidators.add(DateValidatorPointForward.from(it)) }
max?.let { listValidators.add(DateValidatorPointBackward.before(it)) }
val validators = CompositeDateValidator.allOf(listValidators)
-
+
return CalendarConstraints.Builder().setValidator(validators).build()
}
-
+
private fun updateAnswer(text: CharSequence?) {
try {
val localDate = parseDate(text, textInputEditText.context.applicationContext)
@@ -188,7 +191,7 @@ internal object QuestionnaireItemDatePickerViewHolderFactory :
}
}
}
-
+
private fun isTextUpdateRequired(
context: Context,
answer: DateType?,
@@ -207,34 +210,15 @@ internal object QuestionnaireItemDatePickerViewHolderFactory :
internal const val TAG = "date-picker"
internal val ZONE_ID_UTC = ZoneId.of("UTC")
-/**
- * Returns the [AppCompatActivity] if there exists one wrapped inside [ContextThemeWrapper] s, or
- * `null` otherwise.
- *
- * This function is inspired by the function with the same name in `AppCompateDelegateImpl`. See
- * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=1615
- *
- * TODO: find a more robust way to do this as it is not guaranteed that the activity is an
- * AppCompatActivity.
- */
-fun Context.tryUnwrapContext(): AppCompatActivity? {
- var context = this
- while (true) {
- when (context) {
- is AppCompatActivity -> return context
- is ContextThemeWrapper -> context = context.baseContext
- else -> return null
- }
- }
-}
-
internal val DateType.localDate
get() =
- LocalDate.of(
- year,
- month + 1,
- day,
- )
+ if (!this.hasValue()) null
+ else
+ LocalDate.of(
+ year,
+ month + 1,
+ day,
+ )
internal val LocalDate.dateType
get() = DateType(year, monthValue - 1, dayOfMonth)
@@ -245,10 +229,10 @@ internal val Date.localDate
internal fun parseDate(text: CharSequence?, context: Context): LocalDate {
val localDate =
if (isAndroidIcuSupported()) {
- DateFormat.getDateInstance(DateFormat.SHORT).parse(text.toString())
- } else {
- android.text.format.DateFormat.getDateFormat(context).parse(text.toString())
- }
+ DateFormat.getDateInstance(DateFormat.SHORT).parse(text.toString())
+ } else {
+ android.text.format.DateFormat.getDateFormat(context).parse(text.toString())
+ }
.localDate
// date/localDate with year more than 4 digit throws data format exception if deep copy
// operation get performed on QuestionnaireResponse,
@@ -266,4 +250,4 @@ internal fun Int.length() =
when (this) {
0 -> 1
else -> log10(abs(toDouble())).toInt() + 1
- }
+ }
\ No newline at end of file
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactory.kt
index 75b1197589..7ee56e60b1 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactory.kt
@@ -29,6 +29,7 @@ import com.google.android.fhir.datacapture.utilities.localizedDateString
import com.google.android.fhir.datacapture.utilities.localizedString
import com.google.android.fhir.datacapture.utilities.toLocalizedString
import com.google.android.fhir.datacapture.utilities.toLocalizedTimeString
+import com.google.android.fhir.datacapture.utilities.tryUnwrapContext
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.validation.Valid
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogSelectViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogSelectViewHolderFactory.kt
index 64bb479831..32db59590b 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogSelectViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogSelectViewHolderFactory.kt
@@ -29,6 +29,7 @@ import com.google.android.fhir.datacapture.common.datatype.asStringValue
import com.google.android.fhir.datacapture.displayString
import com.google.android.fhir.datacapture.itemControl
import com.google.android.fhir.datacapture.localizedTextSpanned
+import com.google.android.fhir.datacapture.utilities.tryUnwrapContext
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.validation.Valid
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDisplayViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDisplayViewHolderFactory.kt
index 113c56da46..7d27969f2b 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDisplayViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDisplayViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -25,14 +25,17 @@ internal object QuestionnaireItemDisplayViewHolderFactory :
override fun getQuestionnaireItemViewHolderDelegate() =
object : QuestionnaireItemViewHolderDelegate {
private lateinit var header: QuestionnaireItemHeaderView
+ private lateinit var itemMedia: QuestionnaireItemMediaView
override lateinit var questionnaireItemViewItem: QuestionnaireItemViewItem
override fun init(itemView: View) {
header = itemView.findViewById(R.id.header)
+ itemMedia = itemView.findViewById(R.id.item_media)
}
override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) {
header.bind(questionnaireItemViewItem.questionnaireItem)
+ itemMedia.bind(questionnaireItemViewItem.questionnaireItem)
}
override fun displayValidationResult(validationResult: ValidationResult) {
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactory.kt
index 534a37beb4..fdd8e36b17 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactory.kt
@@ -18,6 +18,7 @@ package com.google.android.fhir.datacapture.views
import android.text.InputType
import com.google.android.fhir.datacapture.R
+import java.math.BigDecimal
import org.hl7.fhir.r4.model.Quantity
import org.hl7.fhir.r4.model.QuestionnaireResponse
@@ -34,8 +35,23 @@ internal object QuestionnaireItemEditTextQuantityViewHolderFactory :
override fun getValue(
text: String
): QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent? {
- return text.toDoubleOrNull()?.let {
- QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(Quantity(it))
+ // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial
+ // read default unit from initial, as ideally quantity must specify a unit
+ return text.takeIf { it.isNotBlank() }?.let {
+ val value = BigDecimal(text)
+ val quantity =
+ with(questionnaireItemViewItem.questionnaireItem) {
+ if (this.hasInitial() && this.initialFirstRep.valueQuantity.hasCode())
+ this.initialFirstRep.valueQuantity.let { initial ->
+ Quantity().apply {
+ this.value = value
+ this.code = initial.code
+ this.system = initial.system
+ }
+ }
+ else Quantity().apply { this.value = value }
+ }
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(quantity)
}
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt
index 708663fdd4..71cac7e076 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt
@@ -45,6 +45,7 @@ internal abstract class QuestionnaireItemEditTextViewHolderFactory(
abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputType: Int) :
QuestionnaireItemViewHolderDelegate {
private lateinit var header: QuestionnaireItemHeaderView
+ private lateinit var itemMedia: QuestionnaireItemMediaView
private lateinit var textInputLayout: TextInputLayout
private lateinit var textInputEditText: TextInputEditText
override lateinit var questionnaireItemViewItem: QuestionnaireItemViewItem
@@ -52,6 +53,7 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT
override fun init(itemView: View) {
header = itemView.findViewById(R.id.header)
+ itemMedia = itemView.findViewById(R.id.item_media)
textInputLayout = itemView.findViewById(R.id.text_input_layout)
textInputEditText = itemView.findViewById(R.id.text_input_edit_text)
textInputEditText.setRawInputType(rawInputType)
@@ -80,6 +82,7 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT
override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) {
header.bind(questionnaireItemViewItem.questionnaireItem)
+ itemMedia.bind(questionnaireItemViewItem.questionnaireItem)
textInputLayout.hint = questionnaireItemViewItem.questionnaireItem.localizedFlyoverSpanned
textInputEditText.removeTextChangedListener(textWatcher)
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactory.kt
index 70f17bd0cf..7b99d4d102 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactory.kt
@@ -29,16 +29,19 @@ internal object QuestionnaireItemGroupViewHolderFactory :
override fun getQuestionnaireItemViewHolderDelegate() =
object : QuestionnaireItemViewHolderDelegate {
private lateinit var header: QuestionnaireGroupTypeHeaderView
+ private lateinit var itemMedia: QuestionnaireItemMediaView
private lateinit var error: TextView
override lateinit var questionnaireItemViewItem: QuestionnaireItemViewItem
override fun init(itemView: View) {
header = itemView.findViewById(R.id.header)
+ itemMedia = itemView.findViewById(R.id.item_media)
error = itemView.findViewById(R.id.error)
}
override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) {
header.bind(questionnaireItemViewItem.questionnaireItem)
+ itemMedia.bind(questionnaireItemViewItem.questionnaireItem)
}
override fun displayValidationResult(validationResult: ValidationResult) {
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaView.kt
new file mode 100644
index 0000000000..ee20ce5f4b
--- /dev/null
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaView.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.views
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import androidx.lifecycle.lifecycleScope
+import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.fetchBitmap
+import com.google.android.fhir.datacapture.isImage
+import com.google.android.fhir.datacapture.itemMedia
+import com.google.android.fhir.datacapture.utilities.tryUnwrapContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.hl7.fhir.r4.model.Questionnaire
+
+class QuestionnaireItemMediaView(context: Context, attrs: AttributeSet?) :
+ LinearLayout(context, attrs) {
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.questionnaire_item_media, this, true)
+ }
+
+ private var itemImage: ImageView = findViewById(R.id.item_image)
+
+ fun bind(questionnaireItem: Questionnaire.QuestionnaireItemComponent) {
+ itemImage.setImageBitmap(null)
+
+ questionnaireItem.itemMedia?.let {
+ val activity = context.tryUnwrapContext()!!
+
+ activity.lifecycleScope.launch(Dispatchers.IO) {
+ if (it.isImage) {
+ it.fetchBitmap(itemImage.context)?.run {
+ launch(Dispatchers.Main) {
+ itemImage.visibility = View.VISIBLE
+ itemImage.setImageBitmap(this@run)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt
index 127e1f164b..6d04e041db 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt
@@ -22,6 +22,7 @@ import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView
import com.google.android.fhir.datacapture.validation.ValidationResult
+import com.google.common.annotations.VisibleForTesting
/**
* Factory for [QuestionnaireItemViewHolder].
@@ -50,6 +51,7 @@ abstract class QuestionnaireItemViewHolderFactory(@LayoutRes open val resId: Int
*/
open class QuestionnaireItemViewHolder(
itemView: View,
+ @org.jetbrains.annotations.VisibleForTesting
private val delegate: QuestionnaireItemViewHolderDelegate
) : RecyclerView.ViewHolder(itemView) {
init {
diff --git a/datacapture/src/main/res/layout/questionnaire_item_display_view.xml b/datacapture/src/main/res/layout/questionnaire_item_display_view.xml
index df36d75f7e..813120e1be 100644
--- a/datacapture/src/main/res/layout/questionnaire_item_display_view.xml
+++ b/datacapture/src/main/res/layout/questionnaire_item_display_view.xml
@@ -20,7 +20,7 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/item_margin_horizontal"
android:layout_marginTop="@dimen/padding_default"
- android:orientation="horizontal"
+ android:orientation="vertical"
>
+
+
diff --git a/datacapture/src/main/res/layout/questionnaire_item_edit_text_multi_line_view.xml b/datacapture/src/main/res/layout/questionnaire_item_edit_text_multi_line_view.xml
index e647c7733e..235e9358f5 100644
--- a/datacapture/src/main/res/layout/questionnaire_item_edit_text_multi_line_view.xml
+++ b/datacapture/src/main/res/layout/questionnaire_item_edit_text_multi_line_view.xml
@@ -30,6 +30,12 @@
android:layout_marginBottom="@dimen/padding_default"
/>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsTest.kt
index 94f30ae42e..cec3a04469 100644
--- a/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsTest.kt
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsTest.kt
@@ -16,10 +16,20 @@
package com.google.android.fhir.datacapture
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.os.Build
+import android.util.Base64
+import androidx.test.core.app.ApplicationProvider
import com.google.android.fhir.datacapture.mapping.ITEM_INITIAL_EXPRESSION_URL
+import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication
import com.google.common.truth.Truth.assertThat
+import java.math.BigDecimal
+import java.nio.charset.Charset
import java.util.Locale
+import kotlinx.coroutines.runBlocking
+import org.hl7.fhir.r4.model.Attachment
+import org.hl7.fhir.r4.model.Binary
import org.hl7.fhir.r4.model.BooleanType
import org.hl7.fhir.r4.model.CodeType
import org.hl7.fhir.r4.model.CodeableConcept
@@ -27,18 +37,29 @@ import org.hl7.fhir.r4.model.Coding
import org.hl7.fhir.r4.model.Enumeration
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Extension
+import org.hl7.fhir.r4.model.Quantity
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.StringType
import org.hl7.fhir.r4.utils.ToolingExtensions
+import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+import org.robolectric.util.ReflectionHelpers
@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [Build.VERSION_CODES.P])
+@Config(sdk = [Build.VERSION_CODES.P], application = DataCaptureTestApplication::class)
class MoreQuestionnaireItemComponentsTest {
+ @Before
+ fun setUp() {
+ ReflectionHelpers.setStaticField(DataCapture::class.java, "configuration", null)
+ }
+
@Test
fun itemControl_shouldReturnItemControlCodeDropDown() {
val questionnaireItem =
@@ -1059,6 +1080,37 @@ class MoreQuestionnaireItemComponentsTest {
assertThat(questionItem.itemFirstRep.enableWhenExpression).isNull()
}
+ @Test
+ fun calculatedExpression_shouldReturnExpression() {
+ val questionnaire =
+ Questionnaire.QuestionnaireItemComponent().apply {
+ addExtension(
+ EXTENSION_CALCULATED_EXPRESSION_URL,
+ Expression().apply {
+ this.expression = "today()"
+ this.language = "text/fhirpath"
+ }
+ )
+ }
+ assertThat(questionnaire.calculatedExpression).isNotNull()
+ assertThat(questionnaire.calculatedExpression!!.expression).isEqualTo("today()")
+ }
+
+ @Test
+ fun calculatedExpression_shouldReturnNull() {
+ val questionnaire =
+ Questionnaire.QuestionnaireItemComponent().apply {
+ addExtension(
+ ITEM_INITIAL_EXPRESSION_URL,
+ Expression().apply {
+ this.expression = "today()"
+ this.language = "text/fhirpath"
+ }
+ )
+ }
+ assertThat(questionnaire.calculatedExpression).isNull()
+ }
+
@Test
fun localizedFlyoverSpanned_matchingLocale_shouldReturnFlyover() {
val questionItemList =
@@ -1253,6 +1305,62 @@ class MoreQuestionnaireItemComponentsTest {
.isEqualTo(true)
}
+ @Test
+ fun createQuestionResponseWithQuantityType_ShouldNotSetAnswer_WithValueEmpty() {
+ val question =
+ Questionnaire.QuestionnaireItemComponent(
+ StringType("age"),
+ Enumeration(
+ Questionnaire.QuestionnaireItemTypeEnumFactory(),
+ Questionnaire.QuestionnaireItemType.QUANTITY
+ )
+ )
+ .apply {
+ initial =
+ listOf(
+ Questionnaire.QuestionnaireItemInitialComponent(
+ Quantity().apply {
+ code = "months"
+ system = "http://unitofmeausre.org"
+ }
+ )
+ )
+ }
+
+ val questionResponse = question.createQuestionnaireResponseItem()
+
+ assertThat(questionResponse.answer).isEmpty()
+ }
+
+ @Test
+ fun createQuestionResponseWithQuantityType_ShouldSetAnswer() {
+ val question =
+ Questionnaire.QuestionnaireItemComponent(
+ StringType("age"),
+ Enumeration(
+ Questionnaire.QuestionnaireItemTypeEnumFactory(),
+ Questionnaire.QuestionnaireItemType.QUANTITY
+ )
+ )
+ .apply {
+ initial =
+ listOf(
+ Questionnaire.QuestionnaireItemInitialComponent(
+ Quantity().apply {
+ code = "months"
+ system = "http://unitofmeausre.org"
+ value = BigDecimal("1")
+ }
+ )
+ )
+ }
+
+ val questionResponse = question.createQuestionnaireResponseItem()
+ val answer = questionResponse.answerFirstRep.value as Quantity
+ assertThat(answer.value).isEqualTo(BigDecimal(1))
+ assertThat(answer.code).isEqualTo("months")
+ }
+
@Test
fun entryFormat_missingFormat_shouldReturnNull() {
val questionnaireItem =
@@ -1262,6 +1370,166 @@ class MoreQuestionnaireItemComponentsTest {
assertThat(questionnaireItem.entryFormat).isNull()
}
+ @Test
+ fun `Attachment#fetchBitmap() should return null when Attachment has data and incorrect contentType`() {
+ val attachment =
+ Attachment().apply {
+ data = "some-byte".toByteArray(Charset.forName("UTF-8"))
+ contentType = "document/pdf"
+ }
+ val bitmap: Bitmap?
+ runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) }
+ assertThat(bitmap).isNull()
+ }
+
+ @Test
+ fun `Attachment#fetchBitmap() should return null when Attachment has no data and no url`() {
+ val attachment = Attachment()
+ val bitmap: Bitmap?
+
+ runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) }
+
+ assertThat(bitmap).isNull()
+ }
+
+ @Test
+ fun `Attachment#fetchBitmap() should return Bitmap when Attachment has data and correct contentType`() {
+ val attachment =
+ Attachment().apply {
+ data =
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".toByteArray(
+ Charset.forName("UTF-8")
+ )
+ contentType = "image/png"
+ }
+ val bitmap: Bitmap?
+ runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) }
+ assertThat(bitmap).isNotNull()
+ }
+
+ @Test
+ fun `Attachment#isImage() should return true when Attachment contentType is image`() {
+ val attachment = Attachment().apply { contentType = "image/png" }
+ assertThat(attachment.isImage).isTrue()
+ }
+
+ @Test
+ fun `Attachment#isImage() should return false when Attachment contentType is not image`() {
+ val attachment = Attachment().apply { contentType = "document/pdf" }
+ assertThat(attachment.isImage).isFalse()
+ }
+
+ @Test
+ fun `Attachment#isImage() should return false when Attachment does not have contentType`() {
+ val attachment = Attachment()
+ assertThat(attachment.isImage).isFalse()
+ }
+
+ @Test
+ fun `Binary#getBitmap() should return null when contentType is not for an image`() {
+ val binary = Binary().apply { contentType = "document/pdf" }
+ assertThat(binary.getBitmap()).isNull()
+ }
+
+ @Test
+ fun `Binary#getBitmap() should return null when contentType is null`() {
+ val binary = Binary()
+ assertThat(binary.getBitmap()).isNull()
+ }
+
+ @Test
+ fun `Binary#isImage() should return false when contentType is not for an image`() {
+ val binary = Binary().apply { contentType = "document/pdf" }
+ assertThat(binary.isImage()).isFalse()
+ }
+
+ @Test
+ fun `Binary#isImage() should return false when contentType is null`() {
+ val binary = Binary()
+ assertThat(binary.isImage()).isFalse()
+ }
+
+ @Test
+ fun `Attachment#fetchBitmap() should return Bitmap and call AttachmentResolver#resolveBinaryResource`() {
+ val attachment = Attachment().apply { url = "https://hapi.fhir.org/Binary/f006" }
+ val binary =
+ Binary().apply {
+ contentType = "image/png"
+ data =
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".toByteArray(
+ Charset.forName("UTF-8")
+ )
+ }
+ val bitmap: Bitmap?
+ val attachmentResolver: AttachmentResolver = mock()
+ runBlocking {
+ Mockito.`when`(attachmentResolver.resolveBinaryResource("https://hapi.fhir.org/Binary/f006"))
+ .doReturn(binary)
+ }
+
+ ApplicationProvider.getApplicationContext()
+ .getDataCaptureConfig()
+ .attachmentResolver = attachmentResolver
+
+ runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) }
+
+ assertThat(bitmap).isNotNull()
+ runBlocking {
+ Mockito.verify(attachmentResolver).resolveBinaryResource("https://hapi.fhir.org/Binary/f006")
+ }
+ }
+
+ @Test
+ fun `Attachment#fetchBitmap() should return null when Attachment has Binary resource url but AttachmentResolver not configured`() {
+ val attachment = Attachment().apply { url = "https://hapi.fhir.org/Binary/f006" }
+ val bitmap: Bitmap?
+
+ runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) }
+
+ assertThat(bitmap).isNull()
+ }
+
+ @Test
+ fun `Attachment#fetchBitmap() should return Bitmap and call AttachmentResolver#resolveImageUrl`() {
+ val attachment = Attachment().apply { url = "https://some-image-server.com/images/f0006.png" }
+ val byteArray =
+ Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT)
+ val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
+ val attachmentResolver: AttachmentResolver = mock()
+ runBlocking {
+ Mockito.`when`(
+ attachmentResolver.resolveImageUrl("https://some-image-server.com/images/f0006.png")
+ )
+ .doReturn(bitmap)
+ }
+ var context = ApplicationProvider.getApplicationContext()
+ context.getDataCaptureConfig().attachmentResolver = attachmentResolver
+
+ val resolvedBitmap: Bitmap?
+ runBlocking { resolvedBitmap = attachment.fetchBitmap(context) }
+
+ assertThat(resolvedBitmap).isEqualTo(bitmap)
+ runBlocking {
+ Mockito.verify(attachmentResolver)
+ .resolveImageUrl("https://some-image-server.com/images/f0006.png")
+ }
+ }
+
+ @Test
+ fun `Attachment#fetchBitmap() should return null when Attachment has external url to image but AttachmentResolver is not configured`() {
+ val attachment = Attachment().apply { url = "https://some-image-server.com/images/f0006.png" }
+
+ ApplicationProvider.getApplicationContext()
+ .getDataCaptureConfig()
+ .attachmentResolver = null
+ val resolvedBitmap: Bitmap?
+ runBlocking {
+ resolvedBitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext())
+ }
+
+ assertThat(resolvedBitmap).isNull()
+ }
+
@Test
fun entryFormat_shouldReturnDateFormat() {
val questionnaireItem =
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireFragmentTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireFragmentTest.kt
new file mode 100644
index 0000000000..5e34825194
--- /dev/null
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireFragmentTest.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture
+
+import android.os.Build
+import androidx.core.os.bundleOf
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.fragment.app.testing.withFragment
+import androidx.lifecycle.Lifecycle
+import ca.uhn.fhir.context.FhirContext
+import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_STRING
+import com.google.common.truth.Truth.assertThat
+import org.hl7.fhir.r4.model.Questionnaire
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.P])
+class QuestionnaireFragmentTest {
+
+ @Test
+ fun testFragment_ShouldBeAbleToBuildQuestionnaireResponse() {
+ val questionnaire =
+ Questionnaire().apply {
+ id = "a-questionnaire"
+ addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-link-id"
+ type = Questionnaire.QuestionnaireItemType.BOOLEAN
+ }
+ )
+ }
+ val questionnaireJson =
+ FhirContext.forR4().newJsonParser().encodeResourceToString(questionnaire)
+ val scenario =
+ launchFragmentInContainer(
+ bundleOf(EXTRA_QUESTIONNAIRE_JSON_STRING to questionnaireJson)
+ )
+ scenario.moveToState(Lifecycle.State.RESUMED)
+ scenario.withFragment {
+ assertThat(this.getQuestionnaireResponse()).isNotNull()
+ assertThat(this.getQuestionnaireResponse().item.any { it.linkId == "a-link-id" }).isTrue()
+ }
+ }
+}
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt
index e8cc64f17c..f85aae11fc 100644
--- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt
@@ -29,12 +29,15 @@ import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_REVIEW_PAGE_FIRST
+import com.google.android.fhir.datacapture.common.datatype.asStringValue
import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.views.QuestionnaireItemViewItem
import com.google.common.truth.Truth.assertThat
import java.io.File
+import java.util.Calendar
+import java.util.Date
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlinx.coroutines.Dispatchers
@@ -46,13 +49,17 @@ import org.hl7.fhir.instance.model.api.IBaseResource
import org.hl7.fhir.r4.model.BooleanType
import org.hl7.fhir.r4.model.CodeableConcept
import org.hl7.fhir.r4.model.Coding
+import org.hl7.fhir.r4.model.DateType
+import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.IntegerType
+import org.hl7.fhir.r4.model.Quantity
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.StringType
import org.hl7.fhir.r4.model.ValueSet
import org.hl7.fhir.r4.utils.ToolingExtensions
+import org.junit.Assert
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
@@ -72,29 +79,29 @@ class QuestionnaireViewModelTest(
) {
private lateinit var state: SavedStateHandle
private val context = ApplicationProvider.getApplicationContext()
-
+
@Before
fun setUp() {
state = SavedStateHandle()
check(
ApplicationProvider.getApplicationContext()
- is DataCaptureConfig.Provider
+ is DataCaptureConfig.Provider
) { "Few tests require a custom application class that implements DataCaptureConfig.Provider" }
ReflectionHelpers.setStaticField(DataCapture::class.java, "configuration", null)
}
-
+
@Test
fun stateHasNoQuestionnaire_shouldThrow() {
val errorMessage =
assertFailsWith { QuestionnaireViewModel(context, state) }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo(
"Neither EXTRA_QUESTIONNAIRE_JSON_URI nor EXTRA_QUESTIONNAIRE_JSON_STRING is supplied."
)
}
-
+
@Test
fun stateHasNoQuestionnaireResponse_shouldCopyQuestionnaireUrl() {
val questionnaire =
@@ -102,7 +109,7 @@ class QuestionnaireViewModelTest(
url = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire"
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -110,7 +117,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun stateHasNoQuestionnaireResponse_shouldCopyQuestion() {
val questionnaire =
@@ -125,7 +132,7 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -138,7 +145,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun stateHasNoQuestionnaireResponse_shouldCopyQuestionnaireStructure() {
val questionnaire =
@@ -160,7 +167,7 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -179,7 +186,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun stateHasQuestionnaireResponse_nestedItemsWithinGroupItems_shouldNotThrowException() {
val questionnaire =
@@ -239,10 +246,10 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
createQuestionnaireViewModel(questionnaire, questionnaireResponse)
}
-
+
@Test
fun stateHasQuestionnaireResponse_nestedItemsWithinNonGroupItems_shouldNotThrowException() {
val questionnaire =
@@ -289,15 +296,15 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
createQuestionnaireViewModel(questionnaire, questionnaireResponse)
}
-
+
@Test
fun stateHasQuestionnaireResponse_nonPrimitiveType_shouldNotThrowError() {
val testOption1 = Coding("test", "option", "1")
val testOption2 = Coding("test", "option", "2")
-
+
val questionnaire =
Questionnaire().apply {
id = "a-questionnaire"
@@ -328,10 +335,10 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
createQuestionnaireViewModel(questionnaire, questionnaireResponse)
}
-
+
@Test
fun stateHasQuestionnaireResponse_questionnaireUrlMatches_shouldNotThrowError() {
val questionnaire =
@@ -344,10 +351,10 @@ class QuestionnaireViewModelTest(
id = "a-questionnaire-response"
this.questionnaire = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire"
}
-
+
createQuestionnaireViewModel(questionnaire, questionnaireResponse)
}
-
+
@Test
fun stateHasQuestionnaireResponse_questionnaireUrlDoesNotMatch_shouldThrowError() {
val questionnaire =
@@ -360,19 +367,19 @@ class QuestionnaireViewModelTest(
id = "a-questionnaire-response"
this.questionnaire = "Questionnaire/a-questionnaire"
}
-
+
val errorMessage =
assertFailsWith {
- createQuestionnaireViewModel(questionnaire, questionnaireResponse)
- }
+ createQuestionnaireViewModel(questionnaire, questionnaireResponse)
+ }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo(
"Mismatching Questionnaire http://www.sample-org/FHIR/Resources/Questionnaire/questionnaire-1 and QuestionnaireResponse (for Questionnaire Questionnaire/a-questionnaire)"
)
}
-
+
@Test
fun stateHasQuestionnaireResponse_wrongLinkId_shouldThrowError() {
val questionnaire =
@@ -400,17 +407,17 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val errorMessage =
assertFailsWith {
- createQuestionnaireViewModel(questionnaire, questionnaireResponse)
- }
+ createQuestionnaireViewModel(questionnaire, questionnaireResponse)
+ }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo("Missing questionnaire item for questionnaire response item a-different-link-id")
}
-
+
@Test
fun stateHasQuestionnaireResponse_lessItemsInQuestionnaireResponse_shouldAddTheMissingItem() =
runBlocking {
@@ -442,18 +449,18 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val questionnaireViewModel =
createQuestionnaireViewModel(questionnaire, questionnaireResponse)
val questionnaireItemViewItem = questionnaireViewModel.questionnaireStateFlow.first()
assertThat(questionnaireItemViewItem.items.first().questionnaireItem.linkId)
.isEqualTo(questionnaireResponseWithMissingItem.item.first().linkId)
assertThat(
- questionnaireItemViewItem.items.single().answers.single().valueBooleanType.booleanValue()
- )
+ questionnaireItemViewItem.items.single().answers.single().valueBooleanType.booleanValue()
+ )
.isTrue()
}
-
+
@Test
fun stateHasQuestionnaireResponse_lessItemsInQuestionnaireResponse_shouldCopyAnswer() =
runBlocking {
@@ -500,31 +507,31 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val questionnaireViewModel =
createQuestionnaireViewModel(questionnaire, questionnaireResponse)
val questionnaireItemViewItemList =
questionnaireViewModel.questionnaireStateFlow.first().items
-
+
// Answer to first question should be created from questionnaire
val questionnaireItemViewItem1 = questionnaireItemViewItemList[0]
assertThat(questionnaireItemViewItem1.questionnaireItem.linkId).isEqualTo("q1")
assertThat(questionnaireItemViewItem1.answers.single().valueBooleanType.booleanValue())
.isFalse()
-
+
// Answer to second question should be copied from questionnaire response
val questionnaireItemViewItem2 = questionnaireItemViewItemList[1]
assertThat(questionnaireItemViewItem2.questionnaireItem.linkId).isEqualTo("q2")
assertThat(questionnaireItemViewItem2.answers.single().valueBooleanType.booleanValue())
.isTrue()
-
+
// Answer to third quesiton should be created from questionnaire
val questionnaireItemViewItem3 = questionnaireItemViewItemList[2]
assertThat(questionnaireItemViewItem3.questionnaireItem.linkId).isEqualTo("q3")
assertThat(questionnaireItemViewItem3.answers.single().valueBooleanType.booleanValue())
.isFalse()
}
-
+
@Test
fun stateHasQuestionnaireResponse_wrongType_shouldThrowError() {
val questionnaire =
@@ -552,17 +559,17 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val errorMessage =
assertFailsWith {
- createQuestionnaireViewModel(questionnaire, questionnaireResponse)
- }
+ createQuestionnaireViewModel(questionnaire, questionnaireResponse)
+ }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo("Mismatching question type BOOLEAN and answer type string for a-link-id")
}
-
+
@Test
fun stateHasQuestionnaireResponse_repeatsTrueWithMultipleAnswers_shouldNotThrowError() {
val questionnaire =
@@ -597,12 +604,12 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse)
-
+
assertResourceEquals(questionnaireResponse, viewModel.getQuestionnaireResponse())
}
-
+
@Test
fun stateHasQuestionnaireResponse_repeatsFalseWithMultipleAnswers_shouldThrowError() {
val questionnaire =
@@ -636,17 +643,17 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val errorMessage =
assertFailsWith {
- createQuestionnaireViewModel(questionnaire, questionnaireResponse)
- }
+ createQuestionnaireViewModel(questionnaire, questionnaireResponse)
+ }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo("Multiple answers for non-repeat questionnaire item a-link-id")
}
-
+
@Test
fun questionnaireHasInitialValue_shouldSetAnswerValueInQuestionnaireResponse() {
val questionnaire =
@@ -667,7 +674,7 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -685,7 +692,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun questionnaireHasMultipleInitialValuesForRepeatingCase_shouldSetFirstAnswerValueInQuestionnaireResponse() {
val questionnaire =
@@ -706,7 +713,7 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -724,7 +731,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun questionnaireHasInitialValueButQuestionnaireResponseAsEmpty_shouldSetEmptyAnswer() {
val questionnaire =
@@ -758,7 +765,7 @@ class QuestionnaireViewModelTest(
}
createQuestionnaireViewModel(questionnaire, questionnaireResponse)
}
-
+
@Test
fun `getQuestionnaireResponse() should have text in questionnaire response item`() {
val questionnaire =
@@ -776,7 +783,7 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -794,7 +801,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun `getQuestionnaireResponse() should have translated text in questionnaire response item`() {
val questionnaire =
@@ -819,7 +826,7 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -837,7 +844,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun `getQuestionnaireResponse() should have text in questionnaire response item for nested items`() {
val questionnaire =
@@ -865,7 +872,7 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -890,7 +897,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun `createQuestionnaireViewModel() should throw IllegalArgumentException for multiple initial values with non repeating item`() {
val questionnaire =
@@ -910,17 +917,17 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val errorMessage =
assertFailsWith { createQuestionnaireViewModel(questionnaire) }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo(
"Questionnaire item a-link-id can only have multiple initial values for repeating items. See rule que-13 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial."
)
}
-
+
@Test
fun questionnaireItemMissingType_shouldThrowError() {
val questionnaire =
@@ -933,7 +940,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val questionnaireResponse =
QuestionnaireResponse().apply {
id = "a-questionnaire-response"
@@ -941,16 +948,16 @@ class QuestionnaireViewModelTest(
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "a-link-id" }
)
}
-
+
val errorMessage =
assertFailsWith {
- createQuestionnaireViewModel(questionnaire, questionnaireResponse)
- }
+ createQuestionnaireViewModel(questionnaire, questionnaireResponse)
+ }
.localizedMessage
-
+
assertThat(errorMessage).isEqualTo("Questionnaire item must have type")
}
-
+
@Test
fun questionnaireHasInitialValueAndGroupType_shouldThrowError() {
val questionnaire =
@@ -968,17 +975,17 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val errorMessage =
assertFailsWith { createQuestionnaireViewModel(questionnaire) }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo(
"Questionnaire item a-link-id has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial."
)
}
-
+
@Test
fun questionnaireHasInitialValueAndDisplayType_shouldThrowError() {
val questionnaire =
@@ -996,17 +1003,17 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val errorMessage =
assertFailsWith { createQuestionnaireViewModel(questionnaire) }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo(
"Questionnaire item a-link-id has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial."
)
}
-
+
@Test
fun stateHasQuestionnaireResponse_moreItemsInQuestionnaireResponse_shouldThrowError() {
val questionnaire =
@@ -1044,17 +1051,17 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val errorMessage =
assertFailsWith {
- createQuestionnaireViewModel(questionnaire, questionnaireResponse)
- }
+ createQuestionnaireViewModel(questionnaire, questionnaireResponse)
+ }
.localizedMessage
-
+
assertThat(errorMessage)
.isEqualTo("Missing questionnaire item for questionnaire response item a-different-link-id")
}
-
+
@Test
fun `should emit questionnaire state flow`() = runBlocking {
val questionnaire =
@@ -1075,23 +1082,23 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
val questionnaireItemViewItemList = viewModel.getQuestionnaireItemViewItemList()
assertThat(questionnaireItemViewItemList).hasSize(2)
-
+
val firstQuestionnaireItem = questionnaireItemViewItemList[0].questionnaireItem
assertThat(firstQuestionnaireItem.linkId).isEqualTo("a-link-id")
assertThat(firstQuestionnaireItem.text).isEqualTo("Basic questions")
assertThat(firstQuestionnaireItem.type).isEqualTo(Questionnaire.QuestionnaireItemType.GROUP)
-
+
val secondQuestionnaireItem = questionnaireItemViewItemList[1].questionnaireItem
assertThat(secondQuestionnaireItem.linkId).isEqualTo("another-link-id")
assertThat(secondQuestionnaireItem.text).isEqualTo("Name?")
assertThat(secondQuestionnaireItem.type).isEqualTo(Questionnaire.QuestionnaireItemType.STRING)
}
-
+
@Test
fun `should emit questionnaire state flow without initial validation`() = runBlocking {
val questionnaire =
@@ -1110,7 +1117,7 @@ class QuestionnaireViewModelTest(
val questionnaireItemViewItemList = viewModel.getQuestionnaireItemViewItemList()
assertThat(questionnaireItemViewItemList.single().validationResult).isEqualTo(NotValidated)
}
-
+
@Test
fun `should emit questionnaire state flow with validation for modified items`() = runBlocking {
val questionnaire =
@@ -1125,10 +1132,10 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val viewModel = createQuestionnaireViewModel(questionnaire)
var questionnaireItemViewItem: QuestionnaireItemViewItem? = null
-
+
val observer =
launch(Dispatchers.Main) {
viewModel.questionnaireStateFlow.collect { questionnaireItemViewItem = it.items.single() }
@@ -1136,7 +1143,7 @@ class QuestionnaireViewModelTest(
try {
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
questionnaireItemViewItem!!.clearAnswer()
-
+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
assertThat(questionnaireItemViewItem!!.validationResult)
.isEqualTo(Invalid(listOf("Missing answer for required field.")))
@@ -1146,7 +1153,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should emit questionnaire state flow without disabled questions`() = runBlocking {
val questionnaire =
@@ -1172,11 +1179,11 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
assertThat(viewModel.getQuestionnaireItemViewItemList().single().questionnaireItem.linkId)
.isEqualTo("question-1")
}
-
+
@Test
fun `should emit questionnaire state flow with enabled questions`() = runBlocking {
val questionnaire =
@@ -1202,14 +1209,14 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
val questionnaireItemViewItemList = viewModel.getQuestionnaireItemViewItemList()
-
+
assertThat(questionnaireItemViewItemList).hasSize(2)
assertThat(questionnaireItemViewItemList[0].questionnaireItem.linkId).isEqualTo("question-1")
assertThat(questionnaireItemViewItemList[1].questionnaireItem.linkId).isEqualTo("question-2")
}
-
+
@Test
fun questionnaireHasNestedItem_ofTypeGroup_shouldNestItemWithinItem() = runBlocking {
val questionnaire =
@@ -1251,17 +1258,17 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
viewModel
.getQuestionnaireItemViewItemList()[1].setAnswer(
- QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
- this.value = valueBooleanType.setValue(false)
- }
- )
-
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
+ this.value = valueBooleanType.setValue(false)
+ }
+ )
+
assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse)
}
-
+
@Test
@Ignore("https://github.com/google/android-fhir/issues/487")
fun questionnaireHasNestedItem_notOfTypeGroup_shouldNestItemWithinAnswerItem() = runBlocking {
@@ -1283,7 +1290,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val questionnaireResponse =
QuestionnaireResponse().apply {
addItem(
@@ -1308,23 +1315,23 @@ class QuestionnaireViewModelTest(
)
}
val viewModel = createQuestionnaireViewModel(questionnaire)
-
+
viewModel
.getQuestionnaireItemViewItemList()[0].setAnswer(
- QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
- this.value = valueBooleanType.setValue(false)
- }
- )
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
+ this.value = valueBooleanType.setValue(false)
+ }
+ )
viewModel
.getQuestionnaireItemViewItemList()[1].setAnswer(
- QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
- this.value = valueBooleanType.setValue(false)
- }
- )
-
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
+ this.value = valueBooleanType.setValue(false)
+ }
+ )
+
assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse)
}
-
+
@Test
fun `should show questionnaire items in the active page in a paginated questionnaire`() =
runBlocking {
@@ -1381,7 +1388,7 @@ class QuestionnaireViewModelTest(
assertThat(questionItem.text).isEqualTo("Question on page 1")
}
}
-
+
@Test
fun `should go to next page in a paginated questionnaire`() = runBlocking {
val questionnaire =
@@ -1418,7 +1425,7 @@ class QuestionnaireViewModelTest(
}
val viewModel = createQuestionnaireViewModel(questionnaire)
var pagination: QuestionnairePagination? = null
-
+
val observer =
launch(Dispatchers.Main) {
viewModel.questionnaireStateFlow.collect { pagination = it.pagination }
@@ -1426,7 +1433,7 @@ class QuestionnaireViewModelTest(
try {
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
-
+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
assertThat(pagination)
.isEqualTo(
@@ -1442,7 +1449,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should go to previous page in a paginated questionnaire`() = runBlocking {
val questionnaire =
@@ -1479,7 +1486,7 @@ class QuestionnaireViewModelTest(
}
val viewModel = createQuestionnaireViewModel(questionnaire)
var pagination: QuestionnairePagination? = null
-
+
val observer =
launch(Dispatchers.Main) {
viewModel.questionnaireStateFlow.collect { pagination = it.pagination }
@@ -1488,7 +1495,7 @@ class QuestionnaireViewModelTest(
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
viewModel.goToPreviousPage()
-
+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
assertThat(pagination)
.isEqualTo(
@@ -1504,7 +1511,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should skip disabled page in a paginated questionnaire`() = runBlocking {
val questionnaire =
@@ -1560,7 +1567,7 @@ class QuestionnaireViewModelTest(
}
val viewModel = createQuestionnaireViewModel(questionnaire)
var pagination: QuestionnairePagination? = null
-
+
val observer =
launch(Dispatchers.Main) {
viewModel.questionnaireStateFlow.collect { pagination = it.pagination }
@@ -1568,18 +1575,18 @@ class QuestionnaireViewModelTest(
try {
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
-
+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
assertThat(pagination)
.isEqualTo(
QuestionnairePagination(
isPaginated = true,
pages =
- listOf(
- QuestionnairePage(0, true),
- QuestionnairePage(1, false),
- QuestionnairePage(2, true)
- ),
+ listOf(
+ QuestionnairePage(0, true),
+ QuestionnairePage(1, false),
+ QuestionnairePage(2, true)
+ ),
currentPageIndex = 2
)
)
@@ -1589,7 +1596,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should allow user to move forward using prior entry-mode`() = runBlocking {
val entryModeExtension =
@@ -1640,7 +1647,7 @@ class QuestionnaireViewModelTest(
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
-
+
assertThat(questionnaire.entryMode).isEqualTo(EntryMode.PRIOR_EDIT)
assertThat(pagination)
.isEqualTo(
@@ -1656,7 +1663,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should allow user to move forward and back using prior entry-mode`() = runBlocking {
val entryModeExtension =
@@ -1708,7 +1715,7 @@ class QuestionnaireViewModelTest(
viewModel.goToNextPage()
viewModel.goToPreviousPage()
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
-
+
assertThat(questionnaire.entryMode).isEqualTo(EntryMode.PRIOR_EDIT)
assertThat(pagination)
.isEqualTo(
@@ -1724,7 +1731,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should not allow user to move forward using prior entry-mode`() = runBlocking {
val entryModeExtension =
@@ -1774,7 +1781,7 @@ class QuestionnaireViewModelTest(
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
-
+
assertThat(pagination)
.isEqualTo(
QuestionnairePagination(
@@ -1789,7 +1796,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should allow user to move forward using random entry-mode`() = runBlocking {
val entryModeExtension =
@@ -1832,11 +1839,11 @@ class QuestionnaireViewModelTest(
}
val viewModel = createQuestionnaireViewModel(questionnaire)
viewModel.goToNextPage()
-
+
assertThat(questionnaire.entryMode).isEqualTo(EntryMode.RANDOM)
assertTrue(viewModel.currentPageIndexFlow.value == viewModel.pages?.last()?.index)
}
-
+
@Test
fun `should allow user to move forward and back using random entry-mode`() = runBlocking {
val entryModeExtension =
@@ -1880,11 +1887,11 @@ class QuestionnaireViewModelTest(
val viewModel = createQuestionnaireViewModel(questionnaire)
viewModel.goToNextPage()
viewModel.goToPreviousPage()
-
+
assertThat(questionnaire.entryMode).isEqualTo(EntryMode.RANDOM)
assertTrue(viewModel.currentPageIndexFlow.value == viewModel.pages?.first()?.index)
}
-
+
@Test
fun `should allow user to move forward when no entry-mode is defined`() = runBlocking {
val questionnaire =
@@ -1921,11 +1928,11 @@ class QuestionnaireViewModelTest(
}
val viewModel = createQuestionnaireViewModel(questionnaire)
viewModel.goToNextPage()
-
+
assertThat(viewModel.entryMode).isEqualTo(EntryMode.RANDOM)
assertTrue(viewModel.currentPageIndexFlow.value == viewModel.pages?.last()?.index)
}
-
+
@Test
fun `should allow user to move forward and back when no entry-mode is defined`() = runBlocking {
val questionnaire =
@@ -1963,11 +1970,11 @@ class QuestionnaireViewModelTest(
val viewModel = createQuestionnaireViewModel(questionnaire)
viewModel.goToNextPage()
viewModel.goToPreviousPage()
-
+
assertThat(viewModel.entryMode).isEqualTo(EntryMode.RANDOM)
assertTrue(viewModel.currentPageIndexFlow.value == viewModel.pages?.first()?.index)
}
-
+
@Test
fun `should allow user to move forward only using sequential entry-mode`() = runBlocking {
val entryModeExtension =
@@ -2018,7 +2025,7 @@ class QuestionnaireViewModelTest(
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
-
+
assertThat(questionnaire.entryMode).isEqualTo(EntryMode.SEQUENTIAL)
assertThat(pagination)
.isEqualTo(
@@ -2034,7 +2041,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should not allow user to move forward using sequential entry-mode`() = runBlocking {
val entryModeExtension =
@@ -2084,7 +2091,7 @@ class QuestionnaireViewModelTest(
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
-
+
assertThat(pagination)
.isEqualTo(
QuestionnairePagination(
@@ -2099,7 +2106,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `should not user to move backward only using sequential entry-mode`() = runBlocking {
val entryModeExtension =
@@ -2151,7 +2158,7 @@ class QuestionnaireViewModelTest(
viewModel.goToNextPage()
viewModel.goToPreviousPage()
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
-
+
assertThat(pagination)
.isEqualTo(
QuestionnairePagination(
@@ -2166,7 +2173,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun questionnaire_resolveContainedAnswerValueSet() = runBlocking {
val valueSetId = "yesnodontknow"
@@ -2184,7 +2191,7 @@ class QuestionnaireViewModelTest(
display = "Yes"
}
)
-
+
addContains(
ValueSet.ValueSetExpansionContainsComponent().apply {
system = CODE_SYSTEM_YES_NO
@@ -2192,7 +2199,7 @@ class QuestionnaireViewModelTest(
display = "No"
}
)
-
+
addContains(
ValueSet.ValueSetExpansionContainsComponent().apply {
system = CODE_SYSTEM_YES_NO
@@ -2204,56 +2211,56 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val viewModel = createQuestionnaireViewModel(questionnaire)
val codeSet = viewModel.resolveAnswerValueSet("#$valueSetId")
-
+
assertThat(codeSet.map { it.valueCoding.display })
.containsExactly("Yes", "No", "Don't Know")
.inOrder()
}
-
+
@Test
fun questionnaire_resolveAnswerValueSetExternalResolved() = runBlocking {
val questionnaire = Questionnaire().apply { id = "a-questionnaire" }
-
+
ApplicationProvider.getApplicationContext()
.dataCaptureConfiguration =
DataCaptureConfig(
valueSetResolverExternal =
- object : ExternalAnswerValueSetResolver {
- override suspend fun resolve(uri: String): List {
-
- return if (uri == CODE_SYSTEM_YES_NO)
- listOf(
- Coding().apply {
- system = CODE_SYSTEM_YES_NO
- code = "Y"
- display = "Yes"
- },
- Coding().apply {
- system = CODE_SYSTEM_YES_NO
- code = "N"
- display = "No"
- },
- Coding().apply {
- system = CODE_SYSTEM_YES_NO
- code = "asked-unknown"
- display = "Don't Know"
- }
- )
- else emptyList()
- }
+ object : ExternalAnswerValueSetResolver {
+ override suspend fun resolve(uri: String): List {
+
+ return if (uri == CODE_SYSTEM_YES_NO)
+ listOf(
+ Coding().apply {
+ system = CODE_SYSTEM_YES_NO
+ code = "Y"
+ display = "Yes"
+ },
+ Coding().apply {
+ system = CODE_SYSTEM_YES_NO
+ code = "N"
+ display = "No"
+ },
+ Coding().apply {
+ system = CODE_SYSTEM_YES_NO
+ code = "asked-unknown"
+ display = "Don't Know"
+ }
+ )
+ else emptyList()
}
+ }
)
-
+
val viewModel = createQuestionnaireViewModel(questionnaire)
val codeSet = viewModel.resolveAnswerValueSet(CODE_SYSTEM_YES_NO)
assertThat(codeSet.map { it.valueCoding.display })
.containsExactly("Yes", "No", "Don't Know")
.inOrder()
}
-
+
@Test
fun questionnaireItem_hiddenExtensionTrue_doNotCreateQuestionnaireItemView() = runBlocking {
val questionnaire =
@@ -2271,15 +2278,15 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val serializedQuestionnaire = printer.encodeResourceToString(questionnaire)
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, serializedQuestionnaire)
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertThat(viewModel.getQuestionnaireItemViewItemList()).isEmpty()
}
-
+
@Test
fun questionnaireItem_hiddenExtensionFalse_shouldCreateQuestionnaireItemView() = runBlocking {
val questionnaire =
@@ -2299,13 +2306,13 @@ class QuestionnaireViewModelTest(
}
val serializedQuestionnaire = printer.encodeResourceToString(questionnaire)
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, serializedQuestionnaire)
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertThat(viewModel.getQuestionnaireItemViewItemList().single().questionnaireItem.linkId)
.isEqualTo("a-boolean-item-1")
}
-
+
@Test
fun questionnaireItem_hiddenExtensionValueIsNotBoolean_shouldCreateQuestionnaireItemView() =
runBlocking {
@@ -2326,13 +2333,13 @@ class QuestionnaireViewModelTest(
}
val serializedQuestionnaire = printer.encodeResourceToString(questionnaire)
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, serializedQuestionnaire)
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertThat(viewModel.getQuestionnaireItemViewItemList().single().questionnaireItem.linkId)
.isEqualTo("a-boolean-item-1")
}
-
+
@Test
fun `should return questionnaire response without disabled questions`() = runBlocking {
val questionnaire =
@@ -2358,9 +2365,9 @@ class QuestionnaireViewModelTest(
)
}
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -2377,7 +2384,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun `should return questionnaire response with enabled questions`() = runBlocking {
val questionnaire =
@@ -2403,9 +2410,9 @@ class QuestionnaireViewModelTest(
)
}
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertResourceEquals(
viewModel.getQuestionnaireResponse(),
QuestionnaireResponse().apply {
@@ -2425,7 +2432,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
@Test
fun nestedDisplayItem_parentQuestionItemIsGroup_createQuestionnaireStateItem() = runBlocking {
val questionnaire =
@@ -2448,13 +2455,13 @@ class QuestionnaireViewModelTest(
)
}
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertThat(viewModel.getQuestionnaireItemViewItemList().last().questionnaireItem.linkId)
.isEqualTo("nested-display-question")
}
-
+
@Test
fun `nested display item with instructions code should not be created as questionnaire state item`() =
runBlocking {
@@ -2473,7 +2480,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val questionnaire =
Questionnaire().apply {
id = "a-questionnaire"
@@ -2495,13 +2502,13 @@ class QuestionnaireViewModelTest(
)
}
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertThat(viewModel.getQuestionnaireItemViewItemList().last().questionnaireItem.linkId)
.isEqualTo("parent-question")
}
-
+
@Test
fun `nested display item with flyover code should not be created as questionnaire state item`() =
runBlocking {
@@ -2520,7 +2527,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val questionnaire =
Questionnaire().apply {
id = "a-questionnaire"
@@ -2542,13 +2549,13 @@ class QuestionnaireViewModelTest(
)
}
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertThat(viewModel.getQuestionnaireItemViewItemList().last().questionnaireItem.linkId)
.isEqualTo("parent-question")
}
-
+
@Test
fun `nested display item with help code should not be created as questionnaire state item`() =
runBlocking {
@@ -2567,7 +2574,7 @@ class QuestionnaireViewModelTest(
}
)
}
-
+
val questionnaire =
Questionnaire().apply {
id = "a-questionnaire"
@@ -2589,13 +2596,13 @@ class QuestionnaireViewModelTest(
)
}
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
-
+
val viewModel = QuestionnaireViewModel(context, state)
-
+
assertThat(viewModel.getQuestionnaireItemViewItemList().last().questionnaireItem.linkId)
.isEqualTo("parent-question")
}
-
+
@Test
fun `setShowSubmitButtonFlag() to false should not show submit button`() {
runBlocking {
@@ -2614,7 +2621,7 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showSubmitButton).isFalse()
}
}
-
+
@Test
fun `setShowSubmitButtonFlag() to true should show submit button`() {
runBlocking {
@@ -2633,7 +2640,7 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showSubmitButton).isTrue()
}
}
-
+
@Test
fun `state has review feature and submit button to true should show submit button when moved to review page`() {
runBlocking {
@@ -2653,7 +2660,7 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showSubmitButton).isTrue()
}
}
-
+
@Test
fun `state has no review feature should not show review button`() {
runBlocking {
@@ -2671,7 +2678,7 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isFalse()
}
}
-
+
@Test
fun `state has review feature should show review button`() {
runBlocking {
@@ -2689,7 +2696,7 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isTrue()
}
}
-
+
@Test
fun `state has review feature and show review page first should not show review button`() {
runBlocking {
@@ -2712,7 +2719,7 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isFalse()
}
}
-
+
@Test
fun `state has no review feature but show review page first should not show review button`() {
runBlocking {
@@ -2735,7 +2742,7 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isFalse()
}
}
-
+
@Test
fun `paginated questionnaire with no review feature should not show review button when moved to next page`() =
runBlocking {
@@ -2781,7 +2788,7 @@ class QuestionnaireViewModelTest(
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
-
+
assertThat(pagination?.showReviewButton).isFalse()
} finally {
observer.cancel()
@@ -2789,7 +2796,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `paginated questionnaire with review feature should show review button when moved to next page`() =
runBlocking {
@@ -2835,7 +2842,7 @@ class QuestionnaireViewModelTest(
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
viewModel.goToNextPage()
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
-
+
assertThat(pagination?.showReviewButton).isTrue()
} finally {
observer.cancel()
@@ -2843,7 +2850,7 @@ class QuestionnaireViewModelTest(
observer.cancelAndJoin()
}
}
-
+
@Test
fun `toggle review mode to false should show review button`() {
runBlocking {
@@ -2862,7 +2869,7 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isTrue()
}
}
-
+
@Test
fun `toggle review mode to true should not show review button`() {
runBlocking {
@@ -2881,7 +2888,245 @@ class QuestionnaireViewModelTest(
assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isFalse()
}
}
-
+
+ @Test
+ fun `should calculate value on start for questionnaire item with calculated expression extension`() =
+ runBlocking {
+ val questionnaire =
+ Questionnaire().apply {
+ id = "a-questionnaire"
+ addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-birthdate"
+ type = Questionnaire.QuestionnaireItemType.DATE
+ addExtension().apply {
+ url = EXTENSION_CALCULATED_EXPRESSION_URL
+ setValue(
+ Expression().apply {
+ this.language = "text/fhirpath"
+ this.expression =
+ "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)"
+ }
+ )
+ }
+ }
+ )
+ addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-age-years"
+ type = Questionnaire.QuestionnaireItemType.QUANTITY
+ addInitial(
+ Questionnaire.QuestionnaireItemInitialComponent(Quantity.fromUcum("1", "year"))
+ )
+ }
+ )
+ }
+
+ val viewModel = createQuestionnaireViewModel(questionnaire)
+
+ assertThat(
+ viewModel
+ .getQuestionnaireResponse()
+ .item
+ .single { it.linkId == "a-birthdate" }
+ .answerFirstRep
+ .value
+ .asStringValue()
+ )
+ .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -1) }.asStringValue())
+
+ assertThat(
+ viewModel
+ .getQuestionnaireResponse()
+ .item
+ .single { it.linkId == "a-age-years" }
+ .answerFirstRep
+ .valueQuantity
+ .value
+ .toString()
+ )
+ .isEqualTo("1")
+ }
+
+ @Test
+ fun `should calculate value on change for questionnaire item with calculated expression extension`() =
+ runBlocking {
+ val questionnaire =
+ Questionnaire().apply {
+ id = "a-questionnaire"
+ addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-birthdate"
+ type = Questionnaire.QuestionnaireItemType.DATE
+ addExtension().apply {
+ url = EXTENSION_CALCULATED_EXPRESSION_URL
+ setValue(
+ Expression().apply {
+ this.language = "text/fhirpath"
+ this.expression =
+ "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)"
+ }
+ )
+ }
+ }
+ )
+ addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-age-years"
+ type = Questionnaire.QuestionnaireItemType.INTEGER
+ }
+ )
+ }
+
+ val viewModel = createQuestionnaireViewModel(questionnaire)
+
+ val current =
+ viewModel.getQuestionnaireItemViewItemList().first {
+ it.questionnaireItem.linkId == "a-birthdate"
+ }
+
+ assertThat(current.getQuestionnaireResponseItem().answer).isEmpty()
+
+ viewModel
+ .getQuestionnaireItemViewItemList()
+ .first { it.questionnaireItem.linkId == "a-age-years" }
+ .apply {
+ this.answersChangedCallback(
+ this.questionnaireItem,
+ this.getQuestionnaireResponseItem(),
+ listOf(
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
+ this.value = Quantity.fromUcum("2", "years")
+ }
+ )
+ )
+ }
+
+ val updated =
+ viewModel.getQuestionnaireItemViewItemList().first {
+ it.questionnaireItem.linkId == "a-birthdate"
+ }
+ assertThat(updated.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString)
+ .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -2) }.valueAsString)
+ }
+
+ @Test
+ fun `should detect cyclic dependency for questionnaire item with calculated expression extension in flat list`() =
+ runBlocking {
+ val questionnaire =
+ Questionnaire().apply {
+ id = "a-questionnaire"
+ addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-birthdate"
+ type = Questionnaire.QuestionnaireItemType.DATE
+ addInitial(
+ Questionnaire.QuestionnaireItemInitialComponent(
+ DateType(Date()).apply { add(Calendar.YEAR, -2) }
+ )
+ )
+ addExtension().apply {
+ url = EXTENSION_CALCULATED_EXPRESSION_URL
+ setValue(
+ Expression().apply {
+ this.language = "text/fhirpath"
+ this.expression =
+ "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)"
+ }
+ )
+ }
+ }
+ )
+
+ addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-age-years"
+ type = Questionnaire.QuestionnaireItemType.INTEGER
+ addExtension().apply {
+ url = EXTENSION_CALCULATED_EXPRESSION_URL
+ setValue(
+ Expression().apply {
+ this.language = "text/fhirpath"
+ this.expression =
+ "today().toString().substring(0, 4).toInteger() - %resource.repeat(item).where(linkId='a-birthdate').answer.value.toString().substring(0, 4).toInteger()"
+ }
+ )
+ }
+ }
+ )
+ }
+
+ val exception =
+ Assert.assertThrows(null, IllegalStateException::class.java) {
+ createQuestionnaireViewModel(questionnaire)
+ }
+ assertThat(exception.message)
+ .isEqualTo("a-birthdate and a-age-years have cyclic dependency in expression based extension")
+ }
+
+ @Test
+ fun `should detect cyclic dependency for questionnaire item with calculated expression extension in nested list`() =
+ runBlocking {
+ val questionnaire =
+ Questionnaire().apply {
+ id = "a-questionnaire"
+ addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-birthdate"
+ type = Questionnaire.QuestionnaireItemType.DATE
+ addInitial(
+ Questionnaire.QuestionnaireItemInitialComponent(
+ DateType(Date()).apply { add(Calendar.YEAR, -2) }
+ )
+ )
+ addExtension().apply {
+ url = EXTENSION_CALCULATED_EXPRESSION_URL
+ setValue(
+ Expression().apply {
+ this.language = "text/fhirpath"
+ this.expression =
+ "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)"
+ }
+ )
+ }
+ }
+ )
+ .addItem()
+ .apply {
+ linkId = "a.1"
+ type = Questionnaire.QuestionnaireItemType.GROUP
+ }
+ .addItem()
+ .apply {
+ linkId = "a.1.1"
+ type = Questionnaire.QuestionnaireItemType.GROUP
+ }
+ .addItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ linkId = "a-age-years"
+ type = Questionnaire.QuestionnaireItemType.INTEGER
+ addExtension().apply {
+ url = EXTENSION_CALCULATED_EXPRESSION_URL
+ setValue(
+ Expression().apply {
+ this.language = "text/fhirpath"
+ this.expression =
+ "today().toString().substring(0, 4).toInteger() - %resource.repeat(item).where(linkId='a-birthdate').answer.value.toString().substring(0, 4).toInteger()"
+ }
+ )
+ }
+ }
+ )
+ }
+
+ val exception =
+ Assert.assertThrows(null, IllegalStateException::class.java) {
+ createQuestionnaireViewModel(questionnaire)
+ }
+ assertThat(exception.message)
+ .isEqualTo("a-birthdate and a-age-years have cyclic dependency in expression based extension")
+ }
+
private fun createQuestionnaireViewModel(
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse? = null,
@@ -2900,7 +3145,7 @@ class QuestionnaireViewModelTest(
shadowOf(context.contentResolver)
.registerInputStream(questionnaireUri, questionnaireFile.inputStream())
}
-
+
questionnaireResponse?.let {
if (questionnaireResponseSource == QuestionnaireResponseSource.STRING) {
state.set(
@@ -2922,13 +3167,19 @@ class QuestionnaireViewModelTest(
showReviewPageFirst.let { state.set(EXTRA_SHOW_REVIEW_PAGE_FIRST, it) }
return QuestionnaireViewModel(context, state)
}
-
+
private suspend fun QuestionnaireViewModel.getQuestionnaireItemViewItemList() =
questionnaireStateFlow.first().items
-
+
+ private fun QuestionnaireItemViewItem.getQuestionnaireResponseItem() =
+ ReflectionHelpers.getField(
+ this,
+ "questionnaireResponseItem"
+ )
+
private companion object {
const val CODE_SYSTEM_YES_NO = "http://terminology.hl7.org/CodeSystem/v2-0136"
-
+
private val paginationExtension =
Extension().apply {
url = EXTENSION_ITEM_CONTROL_URL
@@ -2941,13 +3192,13 @@ class QuestionnaireViewModelTest(
)
)
}
-
+
val printer: IParser = FhirContext.forR4().newJsonParser()
-
+
fun assertResourceEquals(r1: IBaseResource, r2: IBaseResource) {
assertThat(printer.encodeResourceToString(r1)).isEqualTo(printer.encodeResourceToString(r2))
}
-
+
@JvmStatic
@Parameters
fun parameters() =
@@ -2970,4 +3221,4 @@ enum class QuestionnaireSource {
enum class QuestionnaireResponseSource {
STRING,
URI
-}
+}
\ No newline at end of file
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/testing/DataCaptureTestApplication.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/testing/DataCaptureTestApplication.kt
index 1d56aabd95..6389d84335 100644
--- a/datacapture/src/test/java/com/google/android/fhir/datacapture/testing/DataCaptureTestApplication.kt
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/testing/DataCaptureTestApplication.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,8 +20,14 @@ import android.app.Application
import com.google.android.fhir.datacapture.DataCaptureConfig
/** Application class when you want to test the DataCaptureConfig.Provider */
-internal class DataCaptureTestApplication : Application(), DataCaptureConfig.Provider {
+class DataCaptureTestApplication : Application(), DataCaptureConfig.Provider {
var dataCaptureConfiguration: DataCaptureConfig? = null
- override fun getDataCaptureConfig() = dataCaptureConfiguration ?: DataCaptureConfig()
+ override fun getDataCaptureConfig(): DataCaptureConfig {
+ if (dataCaptureConfiguration == null) {
+ dataCaptureConfiguration = DataCaptureConfig()
+ }
+
+ return dataCaptureConfiguration!!
+ }
}
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/utilities/MoreContextTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/utilities/MoreContextTest.kt
new file mode 100644
index 0000000000..2d749b4dd1
--- /dev/null
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/utilities/MoreContextTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.utilities
+
+import android.app.Application
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ContextThemeWrapper
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class MoreContextTest {
+
+ @Test
+ fun context_should_return_appCompatActivity() {
+ val context = AppCompatActivity().tryUnwrapContext()
+ assertThat(context).isInstanceOf(AppCompatActivity::class.java)
+ }
+
+ @Test
+ fun context_should_return_baseContext_as_appCompatActivity() {
+ val context = ContextThemeWrapper(AppCompatActivity(), 0).tryUnwrapContext()
+ assertThat(context).isInstanceOf(AppCompatActivity::class.java)
+ }
+
+ @Test
+ fun context_should_return_null() {
+ val context = Application().tryUnwrapContext()
+ assertThat(context).isNull()
+ }
+}
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactoryTest.kt
index 238675122e..9f32292901 100644
--- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactoryTest.kt
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactoryTest.kt
@@ -68,6 +68,26 @@ class QuestionnaireItemDatePickerViewHolderFactoryTest {
assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("")
}
+ @Test
+ fun shouldSetEmptyDateInput_WhenDateFieldInitialized_AndDateIsNull() {
+ viewHolder.bind(
+ QuestionnaireItemViewItem(
+ Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" },
+ QuestionnaireResponse.QuestionnaireResponseItemComponent()
+ .addAnswer(
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(DateType())
+ ),
+ validationResult = null,
+ answersChangedCallback = { _, _, _ -> },
+ )
+ )
+
+ assertThat(
+ viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString()
+ )
+ .isEqualTo("")
+ }
+
@Test
fun shouldSetDateInput_localeUs() {
setLocale(Locale.US)
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt
index f3f4ae9bc5..14808e6219 100644
--- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt
@@ -134,7 +134,7 @@ class QuestionnaireItemEditTextQuantityViewHolderFactoryTest {
val answer = questionnaireItemViewItem.answers
assertThat(answer.size).isEqualTo(1)
- assertThat(answer[0].valueQuantity!!.value!!.toString()).isEqualTo("10.0")
+ assertThat(answer[0].valueQuantity!!.value!!.toString()).isEqualTo("10")
}
@Test
diff --git a/demo/src/main/assets/screener-questionnaire.json b/demo/src/main/assets/screener-questionnaire.json
index 1d5e6cc14c..47eac0facc 100644
--- a/demo/src/main/assets/screener-questionnaire.json
+++ b/demo/src/main/assets/screener-questionnaire.json
@@ -601,35 +601,35 @@
"valueCoding": {
"code": "35489007",
"display": "Depression",
- "system" : "http://snomed.info/sct"
+ "system": "http://snomed.info/sct"
}
},
{
"valueCoding": {
"code": "161445009",
"display": "Diabetes",
- "system" : "http://snomed.info/sct"
+ "system": "http://snomed.info/sct"
}
},
{
"valueCoding": {
"code": "161501007",
"display": "Hypertension",
- "system" : "http://snomed.info/sct"
+ "system": "http://snomed.info/sct"
}
},
{
"valueCoding": {
"code": "56265001",
"display": "Heart disease",
- "system" : "http://snomed.info/sct"
+ "system": "http://snomed.info/sct"
}
},
{
"valueCoding": {
"code": "161450003",
"display": "High blood lipids",
- "system" : "http://snomed.info/sct"
+ "system": "http://snomed.info/sct"
}
}
]
@@ -989,21 +989,21 @@
"valueCoding": {
"code": "NV",
"display": "Not vaccinated",
- "system" : "custom"
+ "system": "custom"
}
},
{
"valueCoding": {
"code": "PV",
"display": "Partially vaccinated",
- "system" : "custom"
+ "system": "custom"
}
},
{
"valueCoding": {
"code": "FV",
"display": "Fully vaccinated",
- "system" : "custom"
+ "system": "custom"
}
}
]
@@ -1017,7 +1017,7 @@
"question": "4.1.0",
"operator": "=",
"answerCoding": {
- "system" : "custom",
+ "system": "custom",
"code": "PV"
}
}
@@ -1027,21 +1027,21 @@
"valueCoding": {
"code": "AZ",
"display": "AstraZeneca",
- "system" : "custom"
+ "system": "custom"
}
},
{
"valueCoding": {
"code": "Pfizer",
"display": "Pfizer BioNTech",
- "system" : "custom"
+ "system": "custom"
}
},
{
"valueCoding": {
"code": "Moderna",
"display": "Moderna",
- "system" : "custom"
+ "system": "custom"
}
}
]
@@ -1055,7 +1055,7 @@
"question": "4.1.0",
"operator": "=",
"answerCoding": {
- "system" : "custom",
+ "system": "custom",
"code": "PV"
}
}
@@ -1070,7 +1070,7 @@
"question": "4.1.0",
"operator": "=",
"answerCoding": {
- "system" : "custom",
+ "system": "custom",
"code": "FV"
}
}
@@ -1105,7 +1105,7 @@
"question": "4.1.0",
"operator": "=",
"answerCoding": {
- "system" : "custom",
+ "system": "custom",
"code": "FV"
}
}
@@ -1144,7 +1144,16 @@
{
"text": "Add instructions for capturing temperature",
"type": "display",
- "linkId": "5.1.0"
+ "linkId": "5.1.0",
+ "extension": [
+ {
+ "url" : "http://hl7.org/fhir/uv/sdc/StructureDefinition/cpg-itemImage",
+ "valueAttachment" : {
+ "contentType": "image/png",
+ "url": "https://hapi.fhir.org/baseR4/Binary/android-fhir-thermometer-image"
+ }
+ }
+ ]
},
{
"linkId": "5.2.0",
diff --git a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt
index d12aa42427..9cdf58af8c 100644
--- a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt
+++ b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt
@@ -23,14 +23,16 @@ import com.google.android.fhir.FhirEngine
import com.google.android.fhir.FhirEngineConfiguration
import com.google.android.fhir.FhirEngineProvider
import com.google.android.fhir.ServerConfiguration
+import com.google.android.fhir.datacapture.DataCaptureConfig
import com.google.android.fhir.demo.data.FhirPeriodicSyncWorker
import com.google.android.fhir.sync.Sync
import com.google.android.fhir.sync.remote.HttpLogger
import timber.log.Timber
-class FhirApplication : Application() {
+class FhirApplication : Application(), DataCaptureConfig.Provider {
// Only initiate the FhirEngine when used for the first time, not when the app is created.
private val fhirEngine: FhirEngine by lazy { constructFhirEngine() }
+ var dataCaptureConfiguration: DataCaptureConfig? = null
override fun onCreate() {
super.onCreate()
@@ -53,6 +55,11 @@ class FhirApplication : Application() {
)
)
Sync.oneTimeSync(this)
+
+ dataCaptureConfiguration =
+ DataCaptureConfig().apply {
+ attachmentResolver = ReferenceAttachmentResolver(this@FhirApplication as Context)
+ }
}
private fun constructFhirEngine(): FhirEngine {
@@ -62,4 +69,7 @@ class FhirApplication : Application() {
companion object {
fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine
}
+
+ override fun getDataCaptureConfig(): DataCaptureConfig =
+ dataCaptureConfiguration ?: DataCaptureConfig()
}
diff --git a/demo/src/main/java/com/google/android/fhir/demo/ReferenceAttachmentResolver.kt b/demo/src/main/java/com/google/android/fhir/demo/ReferenceAttachmentResolver.kt
new file mode 100644
index 0000000000..5f33f586e4
--- /dev/null
+++ b/demo/src/main/java/com/google/android/fhir/demo/ReferenceAttachmentResolver.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.demo
+
+import android.content.Context
+import android.graphics.Bitmap
+import com.google.android.fhir.datacapture.AttachmentResolver
+import com.google.android.fhir.get
+import org.hl7.fhir.r4.model.Binary
+
+class ReferenceAttachmentResolver(val context: Context) : AttachmentResolver {
+
+ override suspend fun resolveBinaryResource(uri: String): Binary? {
+ return uri.substringAfter("Binary/").substringBefore("/").run {
+ FhirApplication.fhirEngine(context).get(this)
+ }
+ }
+
+ override suspend fun resolveImageUrl(uri: String): Bitmap? {
+ return null
+ }
+}
diff --git a/demo/src/main/java/com/google/android/fhir/demo/data/DownloadWorkManagerImpl.kt b/demo/src/main/java/com/google/android/fhir/demo/data/DownloadWorkManagerImpl.kt
index b235951743..c09ec86ff8 100644
--- a/demo/src/main/java/com/google/android/fhir/demo/data/DownloadWorkManagerImpl.kt
+++ b/demo/src/main/java/com/google/android/fhir/demo/data/DownloadWorkManagerImpl.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -29,17 +29,19 @@ import org.hl7.fhir.r4.model.ResourceType
class DownloadWorkManagerImpl : DownloadWorkManager {
private val resourceTypeList = ResourceType.values().map { it.name }
- private val urls = LinkedList(listOf("Patient?address-city=NAIROBI"))
+ override var nextRequestUrl: String? = null
+ private val urls =
+ LinkedList(listOf("Patient?address-city=NAIROBI", "Binary?_id=android-fhir-thermometer-image"))
override suspend fun getNextRequestUrl(context: SyncDownloadContext): String? {
- var url = urls.poll() ?: return null
+ nextRequestUrl = urls.poll() ?: return null
val resourceTypeToDownload =
- ResourceType.fromCode(url.findAnyOf(resourceTypeList, ignoreCase = true)!!.second)
+ ResourceType.fromCode(nextRequestUrl?.findAnyOf(resourceTypeList, ignoreCase = true)!!.second)
context.getLatestTimestampFor(resourceTypeToDownload)?.let {
- url = affixLastUpdatedTimestamp(url!!, it)
+ nextRequestUrl = affixLastUpdatedTimestamp(nextRequestUrl!!, it)
}
- return url
+ return nextRequestUrl
}
override suspend fun processResponse(response: Resource): Collection {
diff --git a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt
index 3bb878c0b9..896cb41c2d 100644
--- a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt
+++ b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt
@@ -34,7 +34,9 @@ import com.google.android.fhir.sync.Resolved
import com.google.android.fhir.toTimeZoneString
import java.time.OffsetDateTime
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
@@ -87,12 +89,13 @@ internal class FhirEngineImpl(private val database: Database, private val contex
conflictResolver: ConflictResolver,
download: suspend (SyncDownloadContext) -> Flow>
) {
+
download(
- object : SyncDownloadContext {
- override suspend fun getLatestTimestampFor(type: ResourceType) = database.lastUpdate(type)
- }
- )
- .collect { resources ->
+ object : SyncDownloadContext {
+ override suspend fun getLatestTimestampFor(type: ResourceType) = database.lastUpdate(type)
+ }
+ )
+ .onEach { resources ->
database.withTransaction {
val resolved =
resolveConflictingResources(
@@ -104,6 +107,10 @@ internal class FhirEngineImpl(private val database: Database, private val contex
saveResolvedResourcesToDatabase(resolved)
}
}
+ .catch { throwable: Throwable ->
+ Timber.e(throwable, "Error saving remote resource to database")
+ }
+ .collect()
}
private suspend fun saveResolvedResourcesToDatabase(resolved: List?) {
@@ -115,9 +122,11 @@ internal class FhirEngineImpl(private val database: Database, private val contex
private suspend fun saveRemoteResourcesToDatabase(resources: List) {
val timeStamps =
- resources.groupBy { it.resourceType }.entries.map {
- SyncedResourceEntity(it.key, it.value.maxOf { it.meta.lastUpdated }.toTimeZoneString())
- }
+ resources
+ .groupBy { it.resourceType }
+ .entries.map {
+ SyncedResourceEntity(it.key, it.value.maxOf { it.meta.lastUpdated }.toTimeZoneString())
+ }
database.insertSyncedResources(timeStamps, resources)
}
@@ -207,7 +216,8 @@ internal class FhirEngineImpl(private val database: Database, private val contex
*/
private val Bundle.BundleEntryResponseComponent.resourceIdAndType: Pair?
get() =
- location?.split("/")?.takeIf { it.size > 3 }?.let {
- it[it.size - 3] to ResourceType.fromCode(it[it.size - 4])
- }
+ location
+ ?.split("/")
+ ?.takeIf { it.size > 3 }
+ ?.let { it[it.size - 3] to ResourceType.fromCode(it[it.size - 4]) }
}
diff --git a/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt
index 994f99fc9d..2144e4c277 100644
--- a/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt
+++ b/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -26,6 +26,9 @@ import org.hl7.fhir.r4.model.Resource
* manager be created or should there be an API to restart a new download job.
*/
interface DownloadWorkManager {
+
+ var nextRequestUrl: String?
+
/**
* Returns the URL for the next download request, or `null` if there is no more download request
* to be issued.
diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt b/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt
index d66b0dc73b..d244a9d20f 100644
--- a/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt
+++ b/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@ import com.google.android.fhir.sync.DownloadWorkManager
import com.google.android.fhir.sync.Downloader
import com.google.android.fhir.sync.ResourceSyncException
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import org.hl7.fhir.r4.model.ResourceType
@@ -38,25 +39,29 @@ internal class DownloaderImpl(
) : Downloader {
private val resourceTypeList = ResourceType.values().map { it.name }
- override suspend fun download(context: SyncDownloadContext): Flow = flow {
- var resourceTypeToDownload: ResourceType = ResourceType.Bundle
- emit(DownloadState.Started(resourceTypeToDownload))
- var url = downloadWorkManager.getNextRequestUrl(context)
- while (url != null) {
- try {
- resourceTypeToDownload =
- ResourceType.fromCode(url.findAnyOf(resourceTypeList, ignoreCase = true)!!.second)
-
- emit(
- DownloadState.Success(
- downloadWorkManager.processResponse(dataSource.download(url!!)).toList()
+ override suspend fun download(context: SyncDownloadContext): Flow =
+ flow {
+ val resourceTypeToDownload: ResourceType = ResourceType.Bundle
+ emit(DownloadState.Started(resourceTypeToDownload))
+ var url = downloadWorkManager.getNextRequestUrl(context)
+ while (url != null) {
+ emit(
+ DownloadState.Success(
+ downloadWorkManager.processResponse(dataSource.download(url)).toList()
+ )
+ )
+ url = downloadWorkManager.getNextRequestUrl(context)
+ }
+ }
+ .catch { throwable: Throwable ->
+ val resourceTypeToDownload =
+ ResourceType.fromCode(
+ downloadWorkManager.nextRequestUrl
+ ?.findAnyOf(resourceTypeList, ignoreCase = true)
+ ?.second
)
+ emit(
+ DownloadState.Failure(ResourceSyncException(resourceTypeToDownload, Exception(throwable)))
)
- } catch (exception: Exception) {
- emit(DownloadState.Failure(ResourceSyncException(resourceTypeToDownload, exception)))
}
-
- url = downloadWorkManager.getNextRequestUrl(context)
- }
- }
}
diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt
index 0ae5b55bdc..39d7a9001e 100644
--- a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt
+++ b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt
@@ -38,6 +38,7 @@ typealias ResourceSearchParams = Map
*/
class ResourceParamsBasedDownloadWorkManager(syncParams: ResourceSearchParams) :
DownloadWorkManager {
+ override var nextRequestUrl: String? = null
private val resourcesToDownloadWithSearchParams = LinkedList(syncParams.entries)
private val urlOfTheNextPagesToDownloadForAResource = LinkedList()
@@ -57,7 +58,8 @@ class ResourceParamsBasedDownloadWorkManager(syncParams: ResourceSearchParams) :
}
}
- "${resourceType.name}?${newParams.concatParams()}"
+ nextRequestUrl = "${resourceType.name}?${newParams.concatParams()}"
+ nextRequestUrl
}
}
@@ -67,9 +69,9 @@ class ResourceParamsBasedDownloadWorkManager(syncParams: ResourceSearchParams) :
}
return if (response is Bundle && response.type == Bundle.BundleType.SEARCHSET) {
- response.link.firstOrNull { component -> component.relation == "next" }?.url?.let { next ->
- urlOfTheNextPagesToDownloadForAResource.add(next)
- }
+ response.link
+ .firstOrNull { component -> component.relation == "next" }
+ ?.url?.let { next -> urlOfTheNextPagesToDownloadForAResource.add(next) }
response.entry.map { it.resource }
} else {
diff --git a/engine/src/test-common/java/com/google/android/fhir/resource/TestingUtils.kt b/engine/src/test-common/java/com/google/android/fhir/resource/TestingUtils.kt
index 87c2880c92..d42cb05689 100644
--- a/engine/src/test-common/java/com/google/android/fhir/resource/TestingUtils.kt
+++ b/engine/src/test-common/java/com/google/android/fhir/resource/TestingUtils.kt
@@ -31,7 +31,6 @@ import java.time.OffsetDateTime
import java.util.Date
import java.util.LinkedList
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.collect
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.Meta
import org.hl7.fhir.r4.model.Patient
@@ -104,7 +103,12 @@ class TestingUtils constructor(private val iParser: IParser) {
) : DownloadWorkManager {
private val urls = LinkedList(queries)
- override suspend fun getNextRequestUrl(context: SyncDownloadContext): String? = urls.poll()
+ override var nextRequestUrl: String? = null
+
+ override suspend fun getNextRequestUrl(context: SyncDownloadContext): String? {
+ nextRequestUrl = urls.poll()
+ return nextRequestUrl
+ }
override suspend fun processResponse(response: Resource): Collection {
val patient = Patient().setMeta(Meta().setLastUpdated(Date()))
@@ -142,12 +146,12 @@ class TestingUtils constructor(private val iParser: IParser) {
download: suspend (SyncDownloadContext) -> Flow>
) {
download(
- object : SyncDownloadContext {
- override suspend fun getLatestTimestampFor(type: ResourceType): String {
- return "123456788"
+ object : SyncDownloadContext {
+ override suspend fun getLatestTimestampFor(type: ResourceType): String {
+ return "123456788"
+ }
}
- }
- )
+ )
.collect {}
}
override suspend fun count(search: Search): Long {
diff --git a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt
index ff6acdc43d..f94651fbdd 100644
--- a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt
+++ b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt
@@ -115,87 +115,91 @@ class ResourceParamsBasedDownloadWorkManagerTest {
@Test
fun getNextRequestUrl_withLastUpdatedTimeProvidedInContext_ShouldAppendGtPrefixToLastUpdatedSearchParam() =
- runBlockingTest {
- val downloadManager =
- ResourceParamsBasedDownloadWorkManager(mapOf(ResourceType.Patient to emptyMap()))
- val url =
- downloadManager.getNextRequestUrl(
- object : SyncDownloadContext {
- override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-06-28"
- }
- )
- assertThat(url).isEqualTo("Patient?_sort=_lastUpdated&_lastUpdated=gt2022-06-28")
- }
+ runBlockingTest {
+ val downloadManager =
+ ResourceParamsBasedDownloadWorkManager(mapOf(ResourceType.Patient to emptyMap()))
+ val url =
+ downloadManager.getNextRequestUrl(
+ object : SyncDownloadContext {
+ override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-06-28"
+ }
+ )
+ assertThat(url).isEqualTo("Patient?_sort=_lastUpdated&_lastUpdated=gt2022-06-28")
+ }
@Test
fun getNextRequestUrl_withLastUpdatedSyncParamProvided_shouldReturnUrlWithExactProvidedLastUpdatedSyncParam() =
- runBlockingTest {
- val downloadManager =
- ResourceParamsBasedDownloadWorkManager(
- mapOf(
- ResourceType.Patient to
- mapOf(
- SyncDataParams.LAST_UPDATED_KEY to "2022-06-28",
- SyncDataParams.SORT_KEY to "status"
- )
+ runBlockingTest {
+ val downloadManager =
+ ResourceParamsBasedDownloadWorkManager(
+ mapOf(
+ ResourceType.Patient to
+ mapOf(
+ SyncDataParams.LAST_UPDATED_KEY to "2022-06-28",
+ SyncDataParams.SORT_KEY to "status"
+ )
+ )
)
- )
- val url =
- downloadManager.getNextRequestUrl(
- object : SyncDownloadContext {
- override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-07-07"
- }
- )
- assertThat(url).isEqualTo("Patient?_lastUpdated=2022-06-28&_sort=status")
- }
+ val url =
+ downloadManager.getNextRequestUrl(
+ object : SyncDownloadContext {
+ override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-07-07"
+ }
+ )
+ assertThat(url).isEqualTo("Patient?_lastUpdated=2022-06-28&_sort=status")
+ }
@Test
fun getNextRequestUrl_withLastUpdatedSyncParamHavingGtPrefix_shouldReturnUrlWithExactProvidedLastUpdatedSyncParam() =
- runBlockingTest {
- val downloadManager =
- ResourceParamsBasedDownloadWorkManager(
- mapOf(ResourceType.Patient to mapOf(SyncDataParams.LAST_UPDATED_KEY to "gt2022-06-28"))
- )
- val url =
- downloadManager.getNextRequestUrl(
- object : SyncDownloadContext {
- override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-07-07"
- }
- )
- assertThat(url).isEqualTo("Patient?_lastUpdated=gt2022-06-28&_sort=_lastUpdated")
- }
+ runBlockingTest {
+ val downloadManager =
+ ResourceParamsBasedDownloadWorkManager(
+ mapOf(ResourceType.Patient to mapOf(SyncDataParams.LAST_UPDATED_KEY to "gt2022-06-28"))
+ )
+ val url =
+ downloadManager.getNextRequestUrl(
+ object : SyncDownloadContext {
+ override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-07-07"
+ }
+ )
+ assertThat(url).isEqualTo("Patient?_lastUpdated=gt2022-06-28&_sort=_lastUpdated")
+ }
@Test
fun getNextRequestUrl_withNullUpdatedTimeStamp_shouldReturnUrlWithoutLastUpdatedQueryParam() =
- runBlockingTest {
- val downloadManager =
- ResourceParamsBasedDownloadWorkManager(
- mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI"))
- )
- val actual =
- downloadManager.getNextRequestUrl(
- object : SyncDownloadContext {
- override suspend fun getLatestTimestampFor(type: ResourceType) = null
- }
- )
- assertThat(actual).isEqualTo("Patient?address-city=NAIROBI&_sort=_lastUpdated")
- }
+ runBlockingTest {
+ val downloadManager =
+ ResourceParamsBasedDownloadWorkManager(
+ mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI"))
+ )
+ val actual =
+ downloadManager.getNextRequestUrl(
+ object : SyncDownloadContext {
+ override suspend fun getLatestTimestampFor(type: ResourceType) = null
+ }
+ )
+ val expectedUrl = "Patient?address-city=NAIROBI&_sort=_lastUpdated"
+ assertThat(downloadManager.nextRequestUrl).isEqualTo(expectedUrl)
+ assertThat(actual).isEqualTo(expectedUrl)
+ }
@Test
fun getNextRequestUrl_withEmptyUpdatedTimeStamp_shouldReturnUrlWithoutLastUpdatedQueryParam() =
- runBlockingTest {
- val downloadManager =
- ResourceParamsBasedDownloadWorkManager(
- mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI"))
- )
- val actual =
- downloadManager.getNextRequestUrl(
- object : SyncDownloadContext {
- override suspend fun getLatestTimestampFor(type: ResourceType) = ""
- }
- )
- assertThat(actual).isEqualTo("Patient?address-city=NAIROBI&_sort=_lastUpdated")
- }
+ runBlockingTest {
+ val downloadManager =
+ ResourceParamsBasedDownloadWorkManager(
+ mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI"))
+ )
+ val actual =
+ downloadManager.getNextRequestUrl(
+ object : SyncDownloadContext {
+ override suspend fun getLatestTimestampFor(type: ResourceType) = ""
+ }
+ )
+ val expectedUrl = "Patient?address-city=NAIROBI&_sort=_lastUpdated"
+ assertThat(downloadManager.nextRequestUrl).isEqualTo(expectedUrl)
+ assertThat(actual).isEqualTo(expectedUrl)
+ }
@Test
fun processResponse_withBundleTypeSearchSet_shouldReturnPatient() = runBlockingTest {