diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt
index 89e5b6c34d..30172a9762 100644
--- a/buildSrc/src/main/kotlin/Releases.kt
+++ b/buildSrc/src/main/kotlin/Releases.kt
@@ -54,7 +54,7 @@ object Releases {
object DataCapture : LibraryArtifact {
override val artifactId = "data-capture"
- override val version = "1.1.0"
+ override val version = "1.2.0"
override val name = "Android FHIR Structured Data Capture Library"
}
@@ -81,7 +81,7 @@ object Releases {
object Knowledge : LibraryArtifact {
override val artifactId = "knowledge"
- override val version = "0.1.0-alpha03"
+ override val version = "0.1.0-beta01"
override val name = "Android FHIR Knowledge Manager Library"
}
diff --git a/catalog/src/main/assets/component_per_question_custom_style.json b/catalog/src/main/assets/component_per_question_custom_style.json
new file mode 100644
index 0000000000..2f2e8f3b86
--- /dev/null
+++ b/catalog/src/main/assets/component_per_question_custom_style.json
@@ -0,0 +1,149 @@
+{
+ "resourceType": "Questionnaire",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Custom style 1",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_1"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "2",
+ "text": "Custom style 2",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "3",
+ "text": "Custom style 3",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "4",
+ "text": "Custom style 4",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_4"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "5",
+ "text": "Custom style 5",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_5"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "6",
+ "text": "Custom style 6",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_6"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "7",
+ "text": "Custom style 7",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_7"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "8",
+ "text": "Custom style 8",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_8"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "9",
+ "text": "Custom style 9",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_9"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ 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 bdafbcd17a..d9e4637ada 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
@@ -152,6 +152,11 @@ class ComponentListViewModel(application: Application, private val state: SavedS
R.string.component_name_location_widget,
"component_location_widget.json",
),
+ QUESTION_ITEM_CUSTOM_STYLE(
+ R.drawable.text_format_48dp,
+ R.string.component_name_per_question_custom_style,
+ "component_per_question_custom_style.json",
+ ),
}
val viewItemList =
@@ -177,6 +182,7 @@ class ComponentListViewModel(application: Application, private val state: SavedS
ViewItem.ComponentItem(Component.ITEM_ANSWER_MEDIA),
ViewItem.ComponentItem(Component.INITIAL_VALUE),
ViewItem.ComponentItem(Component.LOCATION_WIDGET),
+ ViewItem.ComponentItem(Component.QUESTION_ITEM_CUSTOM_STYLE),
)
fun isComponent(context: Context, title: String) =
diff --git a/catalog/src/main/res/drawable/ic_location_on.xml b/catalog/src/main/res/drawable/ic_location_on.xml
index 0f96a89039..9821fffba8 100644
--- a/catalog/src/main/res/drawable/ic_location_on.xml
+++ b/catalog/src/main/res/drawable/ic_location_on.xml
@@ -1,7 +1,7 @@
+
+
+
+
diff --git a/catalog/src/main/res/values-night/colors.xml b/catalog/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000000..d80470737a
--- /dev/null
+++ b/catalog/src/main/res/values-night/colors.xml
@@ -0,0 +1,36 @@
+
+
+
+ #000000
+ #0C0A20
+ #201441
+ #341F63
+ #482A85
+ #5C35A6
+ #7F5FBA
+ #A289CF
+ #C5B3E3
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #000000
+ #000000
+
diff --git a/catalog/src/main/res/values/colors.xml b/catalog/src/main/res/values/colors.xml
index 8a1561f3da..f9b63e732a 100644
--- a/catalog/src/main/res/values/colors.xml
+++ b/catalog/src/main/res/values/colors.xml
@@ -102,4 +102,26 @@
#C4C7C5
#8E918F
+
+
+ #7A9FFF
+ #668FFF
+ #5581FF
+ #476FFF
+ #3B5CFF
+ #3249FF
+ #2936FF
+ #2024FF
+ #1816FF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+
diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml
index 7e09047feb..ae47067784 100644
--- a/catalog/src/main/res/values/strings.xml
+++ b/catalog/src/main/res/values/strings.xml
@@ -37,6 +37,9 @@
Repeated Group
Attachment
Location Widget
+ Per question custom style
Default
Paginated
Review
diff --git a/catalog/src/main/res/values/styles.xml b/catalog/src/main/res/values/styles.xml
index f88c5ea9fa..f2afa052f3 100644
--- a/catalog/src/main/res/values/styles.xml
+++ b/catalog/src/main/res/values/styles.xml
@@ -80,7 +80,10 @@
@@ -98,4 +101,170 @@
- 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codelabs/datacapture/README.md b/codelabs/datacapture/README.md
index 41a8504378..e9a7f9e196 100644
--- a/codelabs/datacapture/README.md
+++ b/codelabs/datacapture/README.md
@@ -76,8 +76,8 @@ of the `app/build.gradle.kts` file of your project:
dependencies {
// ...
- implementation("com.google.android.fhir:data-capture:0.1.0-beta03")
- implementation("androidx.fragment:fragment-ktx:1.4.1")
+ implementation("com.google.android.fhir:data-capture:1.0.0")
+ implementation("androidx.fragment:fragment-ktx:1.5.5")
}
```
@@ -173,6 +173,13 @@ if (savedInstanceState == null) {
add(R.id.fragment_container_view, args = questionnaireParams)
}
}
+// Submit button callback
+supportFragmentManager.setFragmentResultListener(
+ QuestionnaireFragment.SUBMIT_REQUEST_KEY,
+ this,
+) { _, _ ->
+ submitQuestionnaire()
+}
```
Learn more about
@@ -244,12 +251,9 @@ questionnaire is already set up for
Find the `submitQuestionnaire()` method and add the following code:
```kotlin
-lifecycleScope.launch {
- val questionnaire =
- jsonParser.parseResource(questionnaireJsonString) as Questionnaire
- val bundle = ResourceMapper.extract(questionnaire, questionnaireResponse)
- Log.d("extraction result", jsonParser.encodeResourceToString(bundle))
-}
+val questionnaire = jsonParser.parseResource(questionnaireJsonString) as Questionnaire
+val bundle = ResourceMapper.extract(questionnaire, questionnaireResponse)
+Log.d("extraction result", jsonParser.encodeResourceToString(bundle))
```
`ResourceMapper.extract()` requires a HAPI FHIR Questionnaire, which you can
diff --git a/codelabs/datacapture/app/build.gradle.kts b/codelabs/datacapture/app/build.gradle.kts
index f252f95236..4c6cc3949c 100644
--- a/codelabs/datacapture/app/build.gradle.kts
+++ b/codelabs/datacapture/app/build.gradle.kts
@@ -38,15 +38,15 @@ android {
}
dependencies {
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
- implementation("androidx.core:core-ktx:1.12.0")
- implementation("androidx.appcompat:appcompat:1.6.1")
- implementation("com.google.android.material:material:1.10.0")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.5")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
// 3 Add dependencies for Structured Data Capture Library and Fragment KTX
}
diff --git a/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt b/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt
index 74ecbe157b..a7f922c760 100644
--- a/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt
+++ b/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022-2023 Google LLC
+ * Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,8 +17,6 @@
package com.google.codelab.sdclibrary
import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
@@ -32,24 +30,12 @@ class MainActivity : AppCompatActivity() {
// 4.2 Replace with code from the codelab to add a questionnaire fragment.
}
- private fun submitQuestionnaire() {
- // 5 Replace with code from the codelab to get a questionnaire response.
+ private fun submitQuestionnaire() =
+ lifecycleScope.launch {
+ // 5 Replace with code from the codelab to get a questionnaire response.
- // 6 Replace with code from the codelab to extract FHIR resources from QuestionnaireResponse.
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.submit_menu, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId == R.id.submit) {
- submitQuestionnaire()
- return true
+ // 6 Replace with code from the codelab to extract FHIR resources from QuestionnaireResponse.
}
- return super.onOptionsItemSelected(item)
- }
private fun getStringFromAssets(fileName: String): String {
return assets.open(fileName).bufferedReader().use { it.readText() }
diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md
index 9b116e4cbb..67bfd6fabc 100644
--- a/codelabs/engine/README.md
+++ b/codelabs/engine/README.md
@@ -125,7 +125,7 @@ file of your project:
dependencies {
// ...
- implementation("com.google.android.fhir:engine:0.1.0-beta05")
+ implementation("com.google.android.fhir:engine:1.0.0")
}
```
@@ -256,6 +256,8 @@ outlined below will guide you through the process.
override fun getConflictResolver() = AcceptLocalConflictResolver
override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext)
+
+ override fun getUploadStrategy() = UploadStrategy.AllChangesSquashedBundlePut
}
```
@@ -282,7 +284,7 @@ outlined below will guide you through the process.
```kotlin
when (syncJobStatus) {
- is SyncJobStatus.Finished -> {
+ is CurrentSyncJobStatus.Succeeded -> {
Toast.makeText(requireContext(), "Sync Finished", Toast.LENGTH_SHORT).show()
viewModel.searchPatientsByName("")
}
@@ -434,20 +436,20 @@ the UI to update, incorporate the following conditional code block:
```kotlin
viewModelScope.launch {
- val fhirEngine = FhirApplication.fhirEngine(getApplication())
- if (nameQuery.isNotEmpty()) {
- val searchResult = fhirEngine.search {
- filter(
- Patient.NAME,
- {
- modifier = StringFilterModifier.CONTAINS
- value = nameQuery
- },
- )
+ val fhirEngine = FhirApplication.fhirEngine(getApplication())
+ val searchResult = fhirEngine.search {
+ if (nameQuery.isNotEmpty()) {
+ filter(
+ Patient.NAME,
+ {
+ modifier = StringFilterModifier.CONTAINS
+ value = nameQuery
+ },
+ )
+ }
+ }
+ liveSearchedPatients.value = searchResult.map { it.resource }
}
- liveSearchedPatients.value = searchResult.map { it.resource }
- }
-}
```
Here, if the `nameQuery` is not empty, the search function will filter the
diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts
index 75c7ab8f62..23c48e875f 100644
--- a/codelabs/engine/app/build.gradle.kts
+++ b/codelabs/engine/app/build.gradle.kts
@@ -37,18 +37,18 @@ android {
}
dependencies {
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
- implementation("androidx.core:core-ktx:1.12.0")
- implementation("androidx.appcompat:appcompat:1.6.1")
- implementation("com.google.android.material:material:1.10.0")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
- implementation("androidx.work:work-runtime-ktx:2.8.1")
+ implementation("androidx.work:work-runtime-ktx:2.9.1")
testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.5")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
- implementation("com.google.android.fhir:engine:0.1.0-beta05")
- implementation("androidx.fragment:fragment-ktx:1.6.1")
+ implementation("com.google.android.fhir:engine:1.0.0")
+ implementation("androidx.fragment:fragment-ktx:1.8.3")
}
diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt
index 8c260fabf0..b00b3bdf89 100644
--- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt
+++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 Google LLC
+ * Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -35,7 +35,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.fhir.codelabs.engine.databinding.FragmentPatientListViewBinding
-import com.google.android.fhir.sync.SyncJobStatus
+import com.google.android.fhir.sync.CurrentSyncJobStatus
import kotlinx.coroutines.launch
class PatientListFragment : Fragment() {
@@ -75,7 +75,7 @@ class PatientListFragment : Fragment() {
}
}
- private fun handleSyncJobStatus(syncJobStatus: SyncJobStatus) {
+ private fun handleSyncJobStatus(syncJobStatus: CurrentSyncJobStatus) {
// Add code to display Toast when sync job is complete
}
diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt
index b25123a148..3c9a099aa8 100644
--- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt
+++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 Google LLC
+ * Copyright 2023-2024 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,16 +23,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.fhir.search.Order
import com.google.android.fhir.search.search
-import com.google.android.fhir.sync.SyncJobStatus
+import com.google.android.fhir.sync.CurrentSyncJobStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import org.hl7.fhir.r4.model.Patient
class PatientListViewModel(application: Application) : AndroidViewModel(application) {
- private val _pollState = MutableSharedFlow()
+ private val _pollState = MutableSharedFlow()
- val pollState: Flow
+ val pollState: Flow
get() = _pollState
val liveSearchedPatients = MutableLiveData>()
diff --git a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactory.kt b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactory.kt
index a37b545489..96a678b080 100644
--- a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactory.kt
+++ b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactory.kt
@@ -22,7 +22,6 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.LiveBarcodeScanningFragment
import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned
-import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.tryUnwrapContext
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderDelegate
@@ -95,7 +94,7 @@ object BarCodeReaderViewHolderFactory :
} else {
prefixTextView.visibility = View.GONE
}
- textQuestion.text = questionnaireViewItem.questionnaireItem.localizedTextSpanned
+ textQuestion.text = questionnaireViewItem.questionText
setInitial(questionnaireViewItem.answers.singleOrNull(), reScanView)
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt
index b6c357d107..b84a625a10 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt
@@ -112,11 +112,23 @@ class QuestionnaireFragment : Fragment() {
} else {
val errorViewModel: QuestionnaireValidationErrorViewModel by activityViewModels()
errorViewModel.setQuestionnaireAndValidation(viewModel.questionnaire, validationMap)
- QuestionnaireValidationErrorMessageDialogFragment()
- .show(
- requireActivity().supportFragmentManager,
- QuestionnaireValidationErrorMessageDialogFragment.TAG,
- )
+ val validationErrorMessageDialog = QuestionnaireValidationErrorMessageDialogFragment()
+ if (requireArguments().containsKey(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON)) {
+ validationErrorMessageDialog.arguments =
+ Bundle().apply {
+ putBoolean(
+ EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON,
+ requireArguments()
+ .getBoolean(
+ EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON,
+ ),
+ )
+ }
+ }
+ validationErrorMessageDialog.show(
+ requireActivity().supportFragmentManager,
+ QuestionnaireValidationErrorMessageDialogFragment.TAG,
+ )
}
}
}
@@ -407,6 +419,11 @@ class QuestionnaireFragment : Fragment() {
args.add(EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL to value)
}
+ /** Setter to show/hide the Submit anyway button. This button is visible by default. */
+ fun setShowSubmitAnywayButton(value: Boolean) = apply {
+ args.add(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON to value)
+ }
+
@VisibleForTesting fun buildArgs() = bundleOf(*args.toTypedArray())
/** @return A [QuestionnaireFragment] with provided [Bundle] arguments. */
@@ -509,6 +526,12 @@ class QuestionnaireFragment : Fragment() {
internal const val EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL =
"show-navigation-in-default-long-scroll"
+ /**
+ * A [Boolean] extra to show or hide the Submit anyway button in the questionnaire. Default is
+ * true.
+ */
+ internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button"
+
fun builder() = Builder()
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt
index 2db2576875..f57b6949b7 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 Google LLC
+ * Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -51,17 +51,23 @@ internal class QuestionnaireValidationErrorMessageDialogFragment(
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
isCancelable = false
- return MaterialAlertDialogBuilder(requireContext())
- .setView(onCreateCustomView())
- .setPositiveButton(R.string.questionnaire_validation_error_fix_button_text) { dialog, _ ->
+ val currentDialog =
+ MaterialAlertDialogBuilder(requireContext()).setView(onCreateCustomView()).setPositiveButton(
+ R.string.questionnaire_validation_error_fix_button_text,
+ ) { dialog, _ ->
setFragmentResult(RESULT_CALLBACK, bundleOf(RESULT_KEY to RESULT_VALUE_FIX))
dialog?.dismiss()
}
- .setNegativeButton(R.string.questionnaire_validation_error_submit_button_text) { dialog, _ ->
+ if (arguments == null || requireArguments().getBoolean(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, true)) {
+ currentDialog.setNegativeButton(R.string.questionnaire_validation_error_submit_button_text) {
+ dialog,
+ _,
+ ->
setFragmentResult(RESULT_CALLBACK, bundleOf(RESULT_KEY to RESULT_VALUE_SUBMIT))
dialog?.dismiss()
}
- .create()
+ }
+ return currentDialog.create()
}
@VisibleForTesting
@@ -97,6 +103,12 @@ internal class QuestionnaireValidationErrorMessageDialogFragment(
const val RESULT_KEY = "result"
const val RESULT_VALUE_FIX = "result_fix"
const val RESULT_VALUE_SUBMIT = "result_submit"
+
+ /**
+ * A [Boolean] extra to show or hide the Submit anyway button in the questionnaire. Default is
+ * true.
+ */
+ internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button"
}
}
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 fbf425a8bc..35ca593755 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
@@ -419,10 +419,11 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
* Adds empty [QuestionnaireResponseItemComponent]s to `responseItems` so that each
* [QuestionnaireItemComponent] in `questionnaireItems` has at least one corresponding
* [QuestionnaireResponseItemComponent]. This is because user-provided [QuestionnaireResponse]
- * might not contain answers to unanswered or disabled questions. Note : this only applies to
- * [QuestionnaireItemComponent]s nested under a group.
+ * might not contain answers to unanswered or disabled questions. This function should only be
+ * used for unpacked questionnaire.
*/
- private fun addMissingResponseItems(
+ @VisibleForTesting
+ internal fun addMissingResponseItems(
questionnaireItems: List,
responseItems: MutableList,
) {
@@ -446,6 +447,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
responseItems = responseItemMap[it.linkId]!!.single().item,
)
}
+ if (it.type == Questionnaire.QuestionnaireItemType.GROUP && it.repeats) {
+ responseItemMap[it.linkId]!!.forEach { rItem ->
+ addMissingResponseItems(
+ questionnaireItems = it.item,
+ responseItems = rItem.item,
+ )
+ }
+ }
responseItems.addAll(responseItemMap[it.linkId]!!)
}
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt
index 022964c9db..fd4690ea26 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt
@@ -104,3 +104,41 @@ fun appendAsteriskToQuestionText(
}
}
}
+
+internal fun applyCustomOrDefaultStyle(
+ questionnaireItem: Questionnaire.QuestionnaireItemComponent,
+ prefixTextView: TextView,
+ questionTextView: TextView,
+ instructionTextView: TextView,
+) {
+ applyCustomOrDefaultStyle(
+ context = prefixTextView.context,
+ view = prefixTextView,
+ customStyleName =
+ questionnaireItem.readCustomStyleExtension(
+ StyleUrl.PREFIX_TEXT_VIEW,
+ ),
+ defaultStyleResId =
+ getStyleResIdFromAttribute(questionTextView.context, R.attr.questionnaireQuestionTextStyle),
+ )
+ applyCustomOrDefaultStyle(
+ context = questionTextView.context,
+ view = questionTextView,
+ customStyleName =
+ questionnaireItem.readCustomStyleExtension(
+ StyleUrl.QUESTION_TEXT_VIEW,
+ ),
+ defaultStyleResId =
+ getStyleResIdFromAttribute(questionTextView.context, R.attr.questionnaireQuestionTextStyle),
+ )
+ applyCustomOrDefaultStyle(
+ context = instructionTextView.context,
+ view = instructionTextView,
+ customStyleName =
+ questionnaireItem.readCustomStyleExtension(
+ StyleUrl.SUBTITLE_TEXT_VIEW,
+ ),
+ defaultStyleResId =
+ getStyleResIdFromAttribute(questionTextView.context, R.attr.questionnaireSubtitleTextStyle),
+ )
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt
new file mode 100644
index 0000000000..3067d969bd
--- /dev/null
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright 2024 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.extensions
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.view.View
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import com.google.android.fhir.datacapture.R
+
+/**
+ * Applies either a custom style or a default style to the given view based on the provided custom
+ * style name and default style resource ID.
+ *
+ * If the custom style resource name is valid, it applies the custom style to the view. If the
+ * custom style resource name is not valid or not found, it falls back to applying the default style
+ * defined by the given style resource ID. It sets the view's tag to resourceId to indicate that the
+ * custom style has been applied.
+ *
+ * @param context the context used to access resources.
+ * @param view the view to which the style should be applied.
+ * @param customStyleName the name of the custom style to apply.
+ * @param defaultStyleResId the default style resource ID to use if no custom style is found.
+ */
+internal fun applyCustomOrDefaultStyle(
+ context: Context,
+ view: View,
+ customStyleName: String?,
+ defaultStyleResId: Int,
+) {
+ val customStyleResId = customStyleName?.let { getStyleResIdByName(context, it) } ?: 0
+ when {
+ customStyleResId != 0 -> {
+ view.tag = customStyleResId
+ QuestionItemCustomStyle().applyStyle(context, view, customStyleResId)
+ }
+ defaultStyleResId != 0 -> {
+ applyDefaultStyleIfNotApplied(context, view, defaultStyleResId)
+ }
+ }
+}
+
+/**
+ * Applies the default style to the given view if the default style has not already been applied.
+ *
+ * This function checks the `view`'s tag to determine if a style has been previously applied. If the
+ * tag is an integer, it will apply the default style specified by `defaultStyleResId`. After
+ * applying the style, it resets the view's tag to `null` to indicate that the default style has
+ * been applied.
+ *
+ * @param context The context used to access resources and themes.
+ * @param view The view to which the default style will be applied.
+ * @param defaultStyleResId The resource ID of the default style to apply.
+ */
+private fun applyDefaultStyleIfNotApplied(
+ context: Context,
+ view: View,
+ defaultStyleResId: Int,
+) {
+ (view.tag as? Int)?.let {
+ QuestionItemDefaultStyle().applyStyle(context, view, defaultStyleResId)
+ view.tag = null
+ }
+}
+
+/**
+ * Retrieves the resource ID of a style given its name.
+ *
+ * This function uses the `getIdentifier` method to look up the style resource ID based on the style
+ * name provided. If the style name is not found, it returns 0.
+ *
+ * @param context The context used to access resources.
+ * @param styleName The name of the style whose resource ID is to be retrieved.
+ * @return The resource ID of the style, or 0 if the style name is not found.
+ */
+private fun getStyleResIdByName(context: Context, styleName: String): Int {
+ return context.resources.getIdentifier(styleName, "style", context.packageName)
+}
+
+/**
+ * Retrieves the style resource ID associated with a specific attribute from the current theme.
+ *
+ * This function obtains the style resource ID that is linked to a given attribute in the current
+ * theme. It uses the `obtainStyledAttributes` method to fetch the attributes and extract the
+ * resource ID.
+ *
+ * @param context The context to access the current theme and resources.
+ * @param attr The attribute whose associated style resource ID is to be retrieved.
+ * @return The resource ID of the style associated with the specified attribute, or 0 if not found.
+ */
+internal fun getStyleResIdFromAttribute(context: Context, attr: Int): Int {
+ val typedArray = context.theme.obtainStyledAttributes(intArrayOf(attr))
+ val styleResId = typedArray.getResourceId(0, 0)
+ typedArray.recycle()
+ return styleResId
+}
+
+internal abstract class QuestionItemStyle {
+
+ /**
+ * Applies a style to a view.
+ *
+ * @param context The context used to apply the style.
+ * @param view The view to which the style will be applied.
+ * @param styleResId The resource ID of the style to apply.
+ */
+ abstract fun applyStyle(context: Context, view: View, styleResId: Int)
+
+ /**
+ * Applies the style from a TypedArray to a view.
+ *
+ * @param context The context used to apply the style.
+ * @param view The view to which the style will be applied.
+ * @param typedArray The TypedArray containing the style attributes.
+ */
+ internal fun applyStyle(context: Context, view: View, typedArray: TypedArray) {
+ applyGenericViewStyle(context, view, typedArray)
+ if (view is TextView) {
+ applyTextViewSpecificStyle(view, typedArray)
+ }
+ typedArray.recycle()
+ }
+
+ /**
+ * Abstract function to apply generic view styles from a TypedArray.
+ *
+ * @param context The context used to apply the style.
+ * @param view The view to which the style will be applied.
+ * @param typedArray The TypedArray containing the style attributes.
+ */
+ abstract fun applyGenericViewStyle(context: Context, view: View, typedArray: TypedArray)
+
+ /**
+ * Abstract function to apply TextView-specific styles from a TypedArray.
+ *
+ * @param textView The TextView to which the style will be applied.
+ * @param typedArray The TypedArray containing the style attributes.
+ */
+ abstract fun applyTextViewSpecificStyle(textView: TextView, typedArray: TypedArray)
+
+ /**
+ * Applies the background color from a TypedArray to a view.
+ *
+ * @param context The context used to apply the background color.
+ * @param view The view to which the background color will be applied.
+ * @param typedArray The TypedArray containing the background color attribute.
+ * @param index The index of the background color attribute in the TypedArray.
+ */
+ protected fun applyBackgroundColor(
+ context: Context,
+ view: View,
+ typedArray: TypedArray,
+ index: Int,
+ ) {
+ val backgroundColor =
+ typedArray.getColor(index, ContextCompat.getColor(context, android.R.color.transparent))
+ view.setBackgroundColor(backgroundColor)
+ }
+
+ /**
+ * Applies the text appearance from a TypedArray to a TextView.
+ *
+ * @param textView The TextView to which the text appearance will be applied.
+ * @param typedArray The TypedArray containing the text appearance attribute.
+ * @param index The index of the text appearance attribute in the TypedArray.
+ */
+ protected fun applyTextAppearance(textView: TextView, typedArray: TypedArray, index: Int) {
+ val textAppearance = typedArray.getResourceId(index, -1)
+ if (textAppearance != -1) {
+ textView.setTextAppearance(textAppearance)
+ }
+ }
+}
+
+internal class QuestionItemCustomStyle : QuestionItemStyle() {
+ private enum class CustomStyleViewAttributes(val attrId: Int) {
+ TEXT_APPEARANCE(R.styleable.QuestionnaireCustomStyle_questionnaire_textAppearance),
+ BACKGROUND(R.styleable.QuestionnaireCustomStyle_questionnaire_background),
+ }
+
+ override fun applyStyle(context: Context, view: View, styleResId: Int) {
+ val typedArray =
+ context.obtainStyledAttributes(styleResId, R.styleable.QuestionnaireCustomStyle)
+ applyStyle(context, view, typedArray)
+ }
+
+ override fun applyGenericViewStyle(context: Context, view: View, typedArray: TypedArray) {
+ for (i in 0 until typedArray.indexCount) {
+ when (typedArray.getIndex(i)) {
+ CustomStyleViewAttributes.BACKGROUND.attrId -> {
+ applyBackgroundColor(context, view, typedArray, i)
+ }
+ }
+ }
+ }
+
+ override fun applyTextViewSpecificStyle(
+ textView: TextView,
+ typedArray: TypedArray,
+ ) {
+ for (i in 0 until typedArray.indexCount) {
+ when (typedArray.getIndex(i)) {
+ CustomStyleViewAttributes.TEXT_APPEARANCE.attrId -> {
+ applyTextAppearance(textView, typedArray, i)
+ }
+ }
+ }
+ }
+}
+
+internal class QuestionItemDefaultStyle : QuestionItemStyle() {
+ private enum class DefaultStyleViewAttributes(val attrId: Int) {
+ TEXT_APPEARANCE(android.R.attr.textAppearance),
+ BACKGROUND(android.R.attr.background),
+ // Add other attributes you want to apply
+ }
+
+ override fun applyStyle(context: Context, view: View, styleResId: Int) {
+ val attrs = DefaultStyleViewAttributes.values().map { it.attrId }.toIntArray()
+ val typedArray: TypedArray = context.obtainStyledAttributes(styleResId, attrs)
+ applyStyle(context, view, typedArray)
+ }
+
+ override fun applyGenericViewStyle(context: Context, view: View, typedArray: TypedArray) {
+ for (i in 0 until typedArray.indexCount) {
+ when (DefaultStyleViewAttributes.values()[i]) {
+ DefaultStyleViewAttributes.BACKGROUND -> {
+ applyBackgroundColor(context, view, typedArray, i)
+ }
+ else -> {
+ // Ignore view specific attributes.
+ }
+ }
+ }
+ }
+
+ override fun applyTextViewSpecificStyle(
+ textView: TextView,
+ typedArray: TypedArray,
+ ) {
+ for (i in 0 until typedArray.indexCount) {
+ when (DefaultStyleViewAttributes.values()[i]) {
+ DefaultStyleViewAttributes.TEXT_APPEARANCE -> {
+ applyTextAppearance(textView, typedArray, i)
+ }
+ else -> {
+ // applyGenericViewDefaultStyle for other attributes.
+ }
+ }
+ }
+ }
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt
index 55dd4e5738..0acd26c93f 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt
@@ -61,6 +61,13 @@ internal const val EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR =
internal const val EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR =
"https://github.com/google/android-fhir/questionnaire-item-control"
+internal enum class StyleUrl(val url: String) {
+ BASE("https://github.com/google/android-fhir/tree/master/datacapture/android-style"),
+ PREFIX_TEXT_VIEW("prefix_text_view"),
+ QUESTION_TEXT_VIEW("question_text_view"),
+ SUBTITLE_TEXT_VIEW("subtitle_text_view"),
+}
+
// Below URLs exist and are supported by HL7
internal const val EXTENSION_ANSWER_EXPRESSION_URL: String =
@@ -1017,3 +1024,17 @@ val Resource.logicalId: String
get() {
return this.idElement?.idPart.orEmpty()
}
+
+internal fun QuestionnaireItemComponent.readCustomStyleExtension(styleUrl: StyleUrl): String? {
+ // Find the base extension
+ val baseExtension = extension.find { it.url == StyleUrl.BASE.url }
+ baseExtension?.let { ext ->
+ // Extract nested extension based on the given StyleUrl
+ ext.extension.forEach { nestedExt ->
+ if (nestedExt.url == styleUrl.url) {
+ return nestedExt.value.asStringValue()
+ }
+ }
+ }
+ return null
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt
index 4fff12a122..da85cbac4e 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt
@@ -23,6 +23,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import com.google.android.fhir.datacapture.QuestionnaireViewHolderType
import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.extensions.applyCustomOrDefaultStyle
import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility
import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.initHelpViews
@@ -60,5 +61,11 @@ class GroupHeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(con
questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(),
)
visibility = getHeaderViewVisibility(prefix, question, hint)
+ applyCustomOrDefaultStyle(
+ questionnaireViewItem.questionnaireItem,
+ prefixTextView = prefix,
+ questionTextView = question,
+ instructionTextView = hint,
+ )
}
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt
index c48e546f38..7e5e77231d 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt
@@ -23,6 +23,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.appendAsteriskToQuestionText
+import com.google.android.fhir.datacapture.extensions.applyCustomOrDefaultStyle
import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility
import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.initHelpViews
@@ -64,6 +65,12 @@ class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(context,
// Make the entire view GONE if there is nothing to show. This is to avoid an empty row in the
// questionnaire.
visibility = getHeaderViewVisibility(prefix, question, hint)
+ applyCustomOrDefaultStyle(
+ questionnaireViewItem.questionnaireItem,
+ prefixTextView = prefix,
+ questionTextView = question,
+ instructionTextView = hint,
+ )
}
/**
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt
index 15ad260a81..4fb0238ca0 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt
@@ -107,12 +107,14 @@ internal class OptionSelectDialogFragment(
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
)
// Adjust the dialog after the keyboard is on so that OK-CANCEL buttons are visible.
- // SOFT_INPUT_ADJUST_RESIZE is deprecated and the suggested alternative
- // setDecorFitsSystemWindows is available api level 30 and above.
+ // Ideally SOFT_INPUT_ADJUST_RESIZE supposed to be used, but in some devices the
+ // keyboard immediately hide itself after being opened, that's why SOFT_INPUT_ADJUST_PAN
+ // is used instead. There's no issue with setDecorFitsSystemWindows and is only
+ // available for api level 30 and above.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
it.setDecorFitsSystemWindows(false)
} else {
- it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
+ it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
}
}
}
@@ -136,7 +138,13 @@ internal class OptionSelectDialogFragment(
SelectedOptions(
options = currentList.filterIsInstance().map { it.option },
otherOptions =
- currentList.filterIsInstance().map { it.currentText },
+ currentList
+ .filterIsInstance()
+ .filter {
+ it.currentText.isNotEmpty()
+ } // Filters out empty answers when the user inputs nothing into a new option choice
+ // edit text field.
+ .map { it.currentText },
),
)
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt
index 123696c46f..85769b3679 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt
@@ -32,7 +32,6 @@ import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText
import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage
import com.google.android.fhir.datacapture.extensions.itemControl
import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned
-import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.tryUnwrapContext
import com.google.android.fhir.datacapture.validation.ValidationResult
import com.google.android.fhir.datacapture.views.HeaderView
@@ -92,7 +91,10 @@ internal object QuestionnaireItemDialogSelectViewHolderFactory :
View.OnClickListener {
val fragment =
OptionSelectDialogFragment(
- title = questionnaireItem.localizedTextSpanned ?: "",
+ // We use the question text for the dialog title. If there is no question text, we
+ // use flyover text as it is sometimes used in text fields instead of question text.
+ title = questionnaireViewItem.questionText
+ ?: questionnaireItem.localizedFlyoverSpanned ?: "",
config = questionnaireItem.buildConfig(),
selectedOptions = selectedOptions,
)
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt
index 32a2de35c3..a6621b2aba 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022-2023 Google LLC
+ * Copyright 2022-2024 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,7 +26,6 @@ import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility
import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned
import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned
-import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.updateTextAndVisibility
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
@@ -66,7 +65,7 @@ internal object ReviewViewHolderFactory : QuestionnaireItemViewHolderFactory(R.l
questionnaireViewItem.questionnaireItem.localizedPrefixSpanned,
)
question.updateTextAndVisibility(
- questionnaireViewItem.questionnaireItem.localizedTextSpanned,
+ questionnaireViewItem.questionText,
)
hint.updateTextAndVisibility(
questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(),
diff --git a/datacapture/src/main/res/values/attrs.xml b/datacapture/src/main/res/values/attrs.xml
index 46d076ed59..42cc7e5fb1 100644
--- a/datacapture/src/main/res/values/attrs.xml
+++ b/datacapture/src/main/res/values/attrs.xml
@@ -188,4 +188,24 @@
extend Theme.Questionnaire. If unspecified, Theme.Questionnaire will be used. -->
+
+
+
+
+
+
+
+
+
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt
index 37f75add4b..b8b4e98451 100644
--- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt
@@ -16,15 +16,20 @@
package com.google.android.fhir.datacapture
+import android.os.Bundle
import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentFactory
+import androidx.fragment.app.testing.launchFragment
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.fragment.app.testing.withFragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
+import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator
import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
@@ -75,6 +80,93 @@ class QuestionnaireValidationErrorMessageDialogFragmentTest {
}
}
+ @Test
+ fun `check alertDialog when submit anyway button argument is true should show Submit anyway button`() {
+ runTest {
+ val questionnaireValidationErrorMessageDialogArguments = Bundle()
+ questionnaireValidationErrorMessageDialogArguments.putBoolean(
+ QuestionnaireValidationErrorMessageDialogFragment.EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON,
+ true,
+ )
+ with(
+ launchFragment(
+ themeResId = R.style.Theme_Questionnaire,
+ fragmentArgs = questionnaireValidationErrorMessageDialogArguments,
+ ),
+ ) {
+ onFragment { fragment ->
+ assertThat(fragment.dialog).isNotNull()
+ assertThat(fragment.requireDialog().isShowing).isTrue()
+ val alertDialog = fragment.dialog as? AlertDialog
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val positiveButtonText =
+ context.getString(R.string.questionnaire_validation_error_fix_button_text)
+ val negativeButtonText =
+ context.getString(R.string.questionnaire_validation_error_submit_button_text)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text)
+ .isEqualTo(positiveButtonText)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text)
+ .isEqualTo(negativeButtonText)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `check alertDialog when no arguments are passed should show Submit anyway button`() {
+ runTest {
+ with(
+ launchFragment(
+ themeResId = R.style.Theme_Questionnaire,
+ ),
+ ) {
+ onFragment { fragment ->
+ assertThat(fragment.dialog).isNotNull()
+ assertThat(fragment.requireDialog().isShowing).isTrue()
+ val alertDialog = fragment.dialog as? AlertDialog
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val positiveButtonText =
+ context.getString(R.string.questionnaire_validation_error_fix_button_text)
+ val negativeButtonText =
+ context.getString(R.string.questionnaire_validation_error_submit_button_text)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text)
+ .isEqualTo(positiveButtonText)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text)
+ .isEqualTo(negativeButtonText)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `check alertDialog when submit anyway button argument is false should hide Submit anyway button`() {
+ runTest {
+ val validationErrorBundle = Bundle()
+ validationErrorBundle.putBoolean(
+ QuestionnaireValidationErrorMessageDialogFragment.EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON,
+ false,
+ )
+ with(
+ launchFragment(
+ themeResId = R.style.Theme_Questionnaire,
+ fragmentArgs = validationErrorBundle,
+ ),
+ ) {
+ onFragment { fragment ->
+ assertThat(fragment.dialog).isNotNull()
+ assertThat(fragment.requireDialog().isShowing).isTrue()
+ val alertDialog = fragment.dialog as? AlertDialog
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val positiveButtonText =
+ context.getString(R.string.questionnaire_validation_error_fix_button_text)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text)
+ .isEqualTo(positiveButtonText)
+ assertEquals(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text, "")
+ }
+ }
+ }
+ }
+
private suspend fun createTestValidationErrorViewModel(
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse,
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 e31854a0af..e644e4c6d6 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
@@ -1138,6 +1138,141 @@ class QuestionnaireViewModelTest {
}
}
+ @Test
+ fun `should add missing response item inside a repeated group`() {
+ val questionnaireString =
+ """
+ {
+ "resourceType": "Questionnaire",
+ "item": [
+ {
+ "linkId": "1",
+ "type": "group",
+ "text": "Repeated Group",
+ "repeats": true,
+ "item": [
+ {
+ "linkId": "1-1",
+ "type": "date",
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/entryFormat",
+ "valueString": "yyyy-mm-dd"
+ }
+ ]
+ },
+ {
+ "linkId": "1-2",
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .trimIndent()
+
+ val questionnaireResponseString =
+ """
+ {
+ "resourceType": "QuestionnaireResponse",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Repeated Group",
+ "item": [
+ {
+ "linkId": "1-1",
+ "answer": [
+ {
+ "valueDate": "2023-06-14"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "1",
+ "text": "Repeated Group",
+ "item": [
+ {
+ "linkId": "1-1",
+ "answer": [
+ {
+ "valueDate": "2023-06-13"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .trimIndent()
+
+ val expectedQuestionnaireResponseString =
+ """
+ {
+ "resourceType": "QuestionnaireResponse",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Repeated Group",
+ "item": [
+ {
+ "linkId": "1-1",
+ "answer": [
+ {
+ "valueDate": "2023-06-14"
+ }
+ ]
+ },
+ {
+ "linkId": "1-2"
+ }
+ ]
+ },
+ {
+ "linkId": "1",
+ "text": "Repeated Group",
+ "item": [
+ {
+ "linkId": "1-1",
+ "answer": [
+ {
+ "valueDate": "2023-06-13"
+ }
+ ]
+ },
+ {
+ "linkId": "1-2"
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .trimIndent()
+
+ val questionnaire =
+ printer.parseResource(Questionnaire::class.java, questionnaireString) as Questionnaire
+
+ val response =
+ printer.parseResource(QuestionnaireResponse::class.java, questionnaireResponseString)
+ as QuestionnaireResponse
+
+ val expectedResponse =
+ printer.parseResource(QuestionnaireResponse::class.java, expectedQuestionnaireResponseString)
+ as QuestionnaireResponse
+
+ val viewModel = createQuestionnaireViewModel(questionnaire, response)
+
+ runTest {
+ viewModel.addMissingResponseItems(questionnaire.item, response.item)
+ assertResourceEquals(response, expectedResponse)
+ }
+ }
+
// ==================================================================== //
// //
// Questionnaire State Flow //
diff --git a/docs/contrib/prereqs.md b/docs/contrib/prereqs.md
index 302db25745..885d3a8029 100644
--- a/docs/contrib/prereqs.md
+++ b/docs/contrib/prereqs.md
@@ -3,7 +3,7 @@
The following software is recommended for contributing to this project:
* Java 17
-* Android Studio 4.2+
+* Android Studio Koala | 2024.1.1+
* Node.js
* Install e.g. [via package manager](https://nodejs.org/en/download/package-manager/)
* Needed for the `prettier` plugin we use to format `XML` files
diff --git a/docs/use/SDCL/Customize-how-a-Questionnaire-is-displayed.md b/docs/use/SDCL/Customize-how-a-Questionnaire-is-displayed.md
index 3ed391bded..4ac8947ef7 100644
--- a/docs/use/SDCL/Customize-how-a-Questionnaire-is-displayed.md
+++ b/docs/use/SDCL/Customize-how-a-Questionnaire-is-displayed.md
@@ -67,6 +67,140 @@ the new theme you just created:
```
+## Custom Style per Question Item
+
+With this change, you can apply individual custom styles per question item. If a custom style is not mentioned in the question item, the default style will be applied, which is present in the DataCapture module or overridden in the application.
+
+### Add a Custom Style Extension to the Question Item
+
+```json
+{
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "prefix_text_view",
+ "valueString": "CustomStyle_1"
+ },
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_1"
+ },
+ {
+ "url": "subtitle_text_view",
+ "valueString": "CustomStyle_2"
+ }
+ ]
+ }
+ ]
+}
+```
+### Custom Style Extension URL
+"https://github.com/google/android-fhir/tree/master/datacapture/android-style"
+
+It identifies extensions for applying the custom style to a given questionnaire item.
+
+### Question Item View
+* `prefix_text_view`: Used to show the prefix value of the question item.
+* `question_text_view`: Used to show the text value of the question item.
+* `subtitle_text_view`: Used to show the instructions of the question item.
+ For more information about supported views, please see the [Question Item View](https://github.com/google/android-fhir/blob/master/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt).
+
+### Custom Style Values
+In the above example:
+
+`CustomStyle_1` is the custom style for prefix_text_view and question_text_view.
+`CustomStyle_2` is the custom style for subtitle_text_view.
+Both styles are defined in the application.
+
+### Custom Style Attributes
+* `questionnaire_textAppearance`: Specifies the text appearance for the questionnaire text. Example: `@style/TextAppearance.AppCompat.Headline`
+* `questionnaire_background`: Specifies the background for the view. Example: `@color/background_color or #FFFFFF`
+
+For more information on custom style attributes, please see the [QuestionnaireCustomStyle](https://github.com/google/android-fhir/blob/master/datacapture/src/main/res/values/attrs.xml)
+
+### Example Custom Styles
+
+```
+
+
+
+
+
+
+
+
+```
+
+The above custom styles are defined in the `res/values/styles.xml` of the application.
+
+### questionnaire.json with custom style
+```
+{
+ "resourceType": "Questionnaire",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Question text custom style",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "prefix_text_view",
+ "valueString": "CustomStyle_1"
+ },
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_1"
+ },
+ {
+ "url": "subtitle_text_view",
+ "valueString": "CustomStyle_2"
+ }
+ ]
+ }
+ ],
+ "item": [
+ {
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory",
+ "valueCodeableConcept": {
+ "coding": [
+ {
+ "system": "http://hl7.org/fhir/questionnaire-display-category",
+ "code": "instructions"
+ }
+ ]
+ }
+ }
+ ],
+ "linkId": "1.3",
+ "text": "Instructions custom style.",
+ "type": "display"
+ }
+ ]
+ } ]
+}
+```
+
## Custom questionnaire components
The Structured Data Capture Library uses
diff --git a/docs/use/api.md b/docs/use/api.md
index d38c35816e..c9c1365ec0 100644
--- a/docs/use/api.md
+++ b/docs/use/api.md
@@ -1,6 +1,6 @@
# API
* [Engine](api/engine/1.0.0/index.html)
-* [Data Capture](api/data-capture/1.1.0/index.html)
+* [Data Capture](api/data-capture/1.2.0/index.html)
* [Workflow](api/workflow/0.1.0-alpha04/index.html)
-* [Knowledge](api/knowledge/0.1.0-alpha03/index.html)
+* [Knowledge](api/knowledge/0.1.0-beta01/index.html)
diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt
index ee0aede8c9..123c474cc5 100644
--- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt
+++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt
@@ -19,6 +19,8 @@ package com.google.android.fhir.db.impl
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.MediumTest
+import ca.uhn.fhir.context.FhirContext
+import ca.uhn.fhir.context.FhirVersionEnum
import ca.uhn.fhir.rest.gclient.StringClientParam
import ca.uhn.fhir.rest.param.ParamPrefixEnum
import com.google.android.fhir.DateProvider
@@ -4209,6 +4211,142 @@ class DatabaseImplTest {
assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId)
}
+ @Test
+ fun updateResourcePostSync_shouldUpdateResourceId() = runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ database.insert(preSyncPatient)
+ val postSyncResourceId = "patient2"
+ val newVersionId = "1"
+ val lastUpdatedRemote = Instant.now()
+
+ database.updateResourcePostSync(
+ preSyncPatient.logicalId,
+ postSyncResourceId,
+ preSyncPatient.resourceType,
+ newVersionId,
+ lastUpdatedRemote,
+ )
+
+ val patientResourceEntityPostSync =
+ database.selectEntity(preSyncPatient.resourceType, postSyncResourceId)
+ assertThat(patientResourceEntityPostSync.resourceId).isEqualTo(postSyncResourceId)
+ }
+
+ @Test
+ fun updateResourcePostSync_shouldUpdateResourceMeta() = runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ database.insert(preSyncPatient)
+ val postSyncResourceId = "patient2"
+ val newVersionId = "1"
+ val lastUpdatedRemote = Instant.now()
+
+ database.updateResourcePostSync(
+ preSyncPatient.logicalId,
+ postSyncResourceId,
+ preSyncPatient.resourceType,
+ newVersionId,
+ lastUpdatedRemote,
+ )
+
+ val patientResourceEntityPostSync =
+ database.selectEntity(preSyncPatient.resourceType, postSyncResourceId)
+ assertThat(patientResourceEntityPostSync.versionId).isEqualTo(newVersionId)
+ assertThat(patientResourceEntityPostSync.lastUpdatedRemote?.toEpochMilli())
+ .isEqualTo(lastUpdatedRemote.toEpochMilli())
+ }
+
+ @Test
+ fun updateResourcePostSync_shouldDeleteOldResourceId() = runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ database.insert(preSyncPatient)
+ val postSyncResourceId = "patient2"
+
+ database.updateResourcePostSync(
+ preSyncPatient.logicalId,
+ postSyncResourceId,
+ preSyncPatient.resourceType,
+ null,
+ null,
+ )
+
+ val exception =
+ assertThrows(ResourceNotFoundException::class.java) {
+ runBlocking { database.select(ResourceType.Patient, "patient1") }
+ }
+ assertThat(exception.message).isEqualTo("Resource not found with type Patient and id patient1!")
+ }
+
+ @Test
+ fun updateResourcePostSync_shouldUpdateReferringResourceReferenceValue() = runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ val observation =
+ Observation().apply {
+ id = "observation1"
+ subject = Reference().apply { reference = "Patient/patient1" }
+ }
+ database.insert(preSyncPatient, observation)
+ val postSyncResourceId = "patient2"
+ val newVersionId = "1"
+ val lastUpdatedRemote = Instant.now()
+
+ database.updateResourcePostSync(
+ preSyncPatient.logicalId,
+ postSyncResourceId,
+ preSyncPatient.resourceType,
+ newVersionId,
+ lastUpdatedRemote,
+ )
+
+ assertThat(
+ (database.select(ResourceType.Observation, "observation1") as Observation)
+ .subject
+ .reference,
+ )
+ .isEqualTo("Patient/patient2")
+ }
+
+ @Test
+ fun updateResourcePostSync_shouldUpdateReferringResourceReferenceValueInLocalChange() =
+ runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ val observation =
+ Observation().apply {
+ id = "observation1"
+ subject = Reference().apply { reference = "Patient/patient1" }
+ }
+ database.insert(preSyncPatient, observation)
+ val postSyncResourceId = "patient2"
+ val newVersionId = "1"
+ val lastUpdatedRemote = Instant.now()
+
+ database.updateResourcePostSync(
+ preSyncPatient.logicalId,
+ postSyncResourceId,
+ preSyncPatient.resourceType,
+ newVersionId,
+ lastUpdatedRemote,
+ )
+
+ assertThat(
+ (database.select(ResourceType.Observation, "observation1") as Observation)
+ .subject
+ .reference,
+ )
+ .isEqualTo("Patient/patient2")
+ val observationLocalChanges =
+ database.getLocalChanges(
+ observation.resourceType,
+ observation.logicalId,
+ )
+ val observationReferenceValue =
+ (FhirContext.forCached(FhirVersionEnum.R4)
+ .newJsonParser()
+ .parseResource(observationLocalChanges.first().payload) as Observation)
+ .subject
+ .reference
+ assertThat(observationReferenceValue).isEqualTo("Patient/$postSyncResourceId")
+ }
+
@Test // https://github.com/google/android-fhir/issues/2512
fun included_results_sort_ascending_should_have_distinct_resources() = runBlocking {
/**
diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt
index d99c71d21d..b4280911c0 100644
--- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt
+++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt
@@ -296,7 +296,7 @@ class LocalChangeDaoTest {
localChangeDao.updateResourceIdAndReferences(
resourceUuid = patientResourceUuid,
oldResource = patient,
- updatedResource = updatedPatient,
+ updatedResourceId = updatedPatient.logicalId,
)
// assert that Patient's new ID is reflected in the Patient Resource Change
@@ -387,7 +387,7 @@ class LocalChangeDaoTest {
localChangeDao.updateResourceIdAndReferences(
patientResourceUuid,
oldResource = localPatient,
- updatedResource = updatedLocalPatient,
+ updatedResourceId = updatedLocalPatient.logicalId,
)
assertThat(updatedReferences.size).isEqualTo(countAboveLimit)
}
diff --git a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/HttpPostResourceConsolidatorTest.kt b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/HttpPostResourceConsolidatorTest.kt
index 67b1bb258c..0fa872aa93 100644
--- a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/HttpPostResourceConsolidatorTest.kt
+++ b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/HttpPostResourceConsolidatorTest.kt
@@ -20,15 +20,20 @@ import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import ca.uhn.fhir.context.FhirContext
+import ca.uhn.fhir.context.FhirVersionEnum
import com.google.android.fhir.FhirServices
import com.google.android.fhir.db.Database
import com.google.android.fhir.db.ResourceNotFoundException
import com.google.android.fhir.logicalId
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
-import org.hl7.fhir.r4.model.DomainResource
+import org.hl7.fhir.r4.model.Bundle
+import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent
import org.hl7.fhir.r4.model.InstantType
+import org.hl7.fhir.r4.model.Meta
import org.hl7.fhir.r4.model.Observation
+import org.hl7.fhir.r4.model.Patient
+import org.hl7.fhir.r4.model.Reference
import org.hl7.fhir.r4.model.ResourceType
import org.junit.After
import org.junit.Assert.assertThrows
@@ -65,35 +70,21 @@ class HttpPostResourceConsolidatorTest {
}
@Test
- fun consolidate_shouldUpdateResourceId() = runBlocking {
- val patientJsonString =
- """
- {
- "resourceType": "Patient",
- "id": "patient1"
- }
- """
- .trimIndent()
- val patient =
- FhirContext.forR4Cached().newJsonParser().parseResource(patientJsonString) as DomainResource
- database.insert(patient)
- val localChanges = database.getLocalChanges(patient.resourceType, patient.logicalId)
-
- val postSyncPatientJsonString =
- """
- {
- "resourceType": "Patient",
- "id": "patient2",
- "meta": {
- "versionId": "1"
- }
- }
- """
- .trimIndent()
+ fun resourceConsolidator_singleResourceUpload_shouldUpdateNewResourceId() = runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ database.insert(preSyncPatient)
+ val localChanges =
+ database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId)
val postSyncPatient =
- FhirContext.forR4Cached().newJsonParser().parseResource(postSyncPatientJsonString)
- as DomainResource
- postSyncPatient.meta.lastUpdatedElement = InstantType.now()
+ Patient().apply {
+ id = "patient2"
+ meta =
+ Meta().apply {
+ versionId = "1"
+ lastUpdatedElement = InstantType.now()
+ }
+ }
+
val uploadRequestResult =
UploadRequestResult.Success(
listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)),
@@ -102,130 +93,264 @@ class HttpPostResourceConsolidatorTest {
assertThat(database.select(ResourceType.Patient, "patient2").logicalId)
.isEqualTo(postSyncPatient.logicalId)
-
val exception =
assertThrows(ResourceNotFoundException::class.java) {
runBlocking { database.select(ResourceType.Patient, "patient1") }
}
-
assertThat(exception.message).isEqualTo("Resource not found with type Patient and id patient1!")
}
@Test
- fun consolidate_dependentResources_shouldUpdateReferenceValue() = runBlocking {
- val patientJsonString =
- """
- {
- "resourceType": "Patient",
- "id": "patient1"
+ fun resourceConsolidator_singleResourceUpload_shouldUpdateReferenceValueOfReferencingResources() =
+ runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ val observation =
+ Observation().apply {
+ id = "observation1"
+ subject = Reference().apply { reference = "Patient/patient1" }
}
- """
- .trimIndent()
- val patient =
- FhirContext.forR4Cached().newJsonParser().parseResource(patientJsonString) as DomainResource
- val observationJsonString =
+ database.insert(preSyncPatient, observation)
+ val postSyncPatient =
+ Patient().apply {
+ id = "patient2"
+ meta =
+ Meta().apply {
+ versionId = "1"
+ lastUpdatedElement = InstantType.now()
+ }
+ }
+ val localChanges =
+ database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId)
+ val uploadRequestResult =
+ UploadRequestResult.Success(
+ listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)),
+ )
+
+ resourceConsolidator.consolidate(uploadRequestResult)
+
+ assertThat(
+ (database.select(ResourceType.Observation, "observation1") as Observation)
+ .subject
+ .reference,
+ )
+ .isEqualTo("Patient/patient2")
+ }
+
+ @Test
+ fun resourceConsolidator_singleResourceUpload_shouldUpdateReferenceValueOfLocalReferencingResources() =
+ runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ val observation =
+ Observation().apply {
+ id = "observation1"
+ subject = Reference().apply { reference = "Patient/patient1" }
+ }
+ database.insert(preSyncPatient, observation)
+ val postSyncPatient =
+ Patient().apply {
+ id = "patient2"
+ meta =
+ Meta().apply {
+ versionId = "1"
+ lastUpdatedElement = InstantType.now()
+ }
+ }
+ val localChanges =
+ database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId)
+ val uploadRequestResult =
+ UploadRequestResult.Success(
+ listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)),
+ )
+
+ resourceConsolidator.consolidate(uploadRequestResult)
+
+ val localChange = database.getLocalChanges(ResourceType.Observation, "observation1").last()
+ assertThat(
+ (FhirContext.forR4Cached().newJsonParser().parseResource(localChange.payload)
+ as Observation)
+ .subject
+ .reference,
+ )
+ .isEqualTo("Patient/patient2")
+ }
+
+ @Test
+ fun resourceConsolidator_bundleComponentUploadResponse_shouldUpdateNewResourceId() = runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ database.insert(preSyncPatient)
+ val localChanges =
+ database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId)
+ val bundleEntryComponentJsonString =
"""
{
- "resourceType": "Observation",
- "id": "observation1",
- "subject": {
- "reference": "Patient/patient1"
- }
+ "resourceType": "Bundle",
+ "id": "bundle1",
+ "type": "transaction-response",
+ "entry": [
+ {
+ "response": {
+ "status": "201 Created",
+ "location": "Patient/patient2/_history/1",
+ "etag": "1",
+ "lastModified": "2024-04-08T11:15:42.648+00:00",
+ "outcome": {
+ "resourceType": "OperationOutcome"
+ }
+ }
+ },
+ {
+ "response": {
+ "status": "201 Created",
+ "location": "Encounter/8055/_history/1",
+ "etag": "1",
+ "lastModified": "2024-04-08T11:15:42.648+00:00",
+ "outcome": {
+ "resourceType": "OperationOutcome"
+ }
+ }
+ }
+ ]
}
"""
.trimIndent()
- val observation =
- FhirContext.forR4Cached().newJsonParser().parseResource(observationJsonString)
- as DomainResource
- database.insert(patient, observation)
- val postSyncPatientJsonString =
- """
- {
- "resourceType": "Patient",
- "id": "patient2",
- "meta": {
- "versionId": "1"
- }
- }
- """
- .trimIndent()
- val postSyncPatient =
- FhirContext.forR4Cached().newJsonParser().parseResource(postSyncPatientJsonString)
- as DomainResource
- postSyncPatient.meta.lastUpdatedElement = InstantType.now()
- val localChanges = database.getLocalChanges(patient.resourceType, patient.logicalId)
+
+ val postSyncResponseBundle =
+ FhirContext.forCached(FhirVersionEnum.R4)
+ .newJsonParser()
+ .parseResource(bundleEntryComponentJsonString) as Bundle
+ val patientResponseEntry =
+ (postSyncResponseBundle.entry.first() as BundleEntryComponent).response
val uploadRequestResult =
UploadRequestResult.Success(
- listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)),
+ listOf(BundleComponentUploadResponseMapping(localChanges, patientResponseEntry)),
)
resourceConsolidator.consolidate(uploadRequestResult)
- assertThat(
- (database.select(ResourceType.Observation, "observation1") as Observation)
- .subject
- .reference,
- )
- .isEqualTo("Patient/patient2")
+ assertThat(database.select(ResourceType.Patient, "patient2").logicalId)
+ .isEqualTo(patientResponseEntry.resourceIdAndType?.first)
+
+ val exception =
+ assertThrows(ResourceNotFoundException::class.java) {
+ runBlocking { database.select(ResourceType.Patient, "patient1") }
+ }
+
+ assertThat(exception.message).isEqualTo("Resource not found with type Patient and id patient1!")
}
@Test
- fun consolidate_localChanges_shouldUpdateReferenceValue() = runBlocking {
- val patientJsonString =
- """
+ fun resourceConsolidator_bundleComponentUploadResponse_shouldUpdateReferenceValueOfReferencingResources() =
+ runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ val preSyncObservation =
+ Observation().apply {
+ id = "observation1"
+ subject = Reference("Patient/patient1")
+ }
+ database.insert(preSyncPatient, preSyncObservation)
+ val patientLocalChanges =
+ database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId)
+ val observationLocalChanges =
+ database.getLocalChanges(preSyncObservation.resourceType, preSyncObservation.logicalId)
+ val bundleEntryComponentJsonString =
+ """
{
- "resourceType": "Patient",
- "id": "patient1"
+ "resourceType": "Bundle",
+ "id": "bundle1",
+ "type": "transaction-response",
+ "entry": [
+ {
+ "response": {
+ "status": "201 Created",
+ "location": "Patient/patient2/_history/1",
+ "etag": "1",
+ "lastModified": "2024-04-08T11:15:42.648+00:00",
+ "outcome": {
+ "resourceType": "OperationOutcome"
+ }
+ }
+ },
+ {
+ "response": {
+ "status": "201 Created",
+ "location": "Observation/observation2/_history/1",
+ "etag": "1",
+ "lastModified": "2024-04-08T11:15:42.648+00:00",
+ "outcome": {
+ "resourceType": "OperationOutcome"
+ }
+ }
+ }
+ ]
}
"""
- .trimIndent()
- val patient =
- FhirContext.forR4Cached().newJsonParser().parseResource(patientJsonString) as DomainResource
- val observationJsonString =
+ .trimIndent()
+
+ val postSyncResponseBundle =
+ FhirContext.forCached(FhirVersionEnum.R4)
+ .newJsonParser()
+ .parseResource(bundleEntryComponentJsonString) as Bundle
+
+ val patientResponseEntry =
+ (postSyncResponseBundle.entry.first() as BundleEntryComponent).response
+ val observationResponseEntry =
+ (postSyncResponseBundle.entry[1] as BundleEntryComponent).response
+
+ val uploadRequestResult =
+ UploadRequestResult.Success(
+ listOf(
+ BundleComponentUploadResponseMapping(patientLocalChanges, patientResponseEntry),
+ BundleComponentUploadResponseMapping(observationLocalChanges, observationResponseEntry),
+ ),
+ )
+
+ resourceConsolidator.consolidate(uploadRequestResult)
+
+ assertThat(
+ (database.select(ResourceType.Observation, "observation2") as Observation)
+ .subject
+ .reference,
+ )
+ .isEqualTo("Patient/patient2")
+ }
+
+ @Test
+ fun resourceConsolidator_bundleComponentUploadResponse_shouldDiscardLocalChanges() = runBlocking {
+ val preSyncPatient = Patient().apply { id = "patient1" }
+ database.insert(preSyncPatient)
+ val localChanges =
+ database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId)
+ val bundleEntryComponentJsonString =
"""
{
- "resourceType": "Observation",
- "id": "observation1",
- "subject": {
- "reference": "Patient/patient1"
- }
+ "resourceType": "Bundle",
+ "id": "bundle1",
+ "type": "transaction-response",
+ "entry": [
+ {
+ "response": {
+ "status": "201 Created",
+ "location": "Patient/patient2/_history/1",
+ "etag": "1"
+ }
+ }
+ ]
}
"""
.trimIndent()
- val observation =
- FhirContext.forR4Cached().newJsonParser().parseResource(observationJsonString)
- as DomainResource
- database.insert(patient, observation)
- val postSyncPatientJsonString =
- """
- {
- "resourceType": "Patient",
- "id": "patient2",
- "meta": {
- "versionId": "1"
- }
- }
- """
- .trimIndent()
- val postSyncPatient =
- FhirContext.forR4Cached().newJsonParser().parseResource(postSyncPatientJsonString)
- as DomainResource
- postSyncPatient.meta.lastUpdatedElement = InstantType.now()
- val localChanges = database.getLocalChanges(patient.resourceType, patient.logicalId)
+ val postSyncResponseBundle =
+ FhirContext.forCached(FhirVersionEnum.R4)
+ .newJsonParser()
+ .parseResource(bundleEntryComponentJsonString) as Bundle
+ val patientResponseEntry =
+ (postSyncResponseBundle.entry.first() as BundleEntryComponent).response
val uploadRequestResult =
UploadRequestResult.Success(
- listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)),
+ listOf(BundleComponentUploadResponseMapping(localChanges, patientResponseEntry)),
)
resourceConsolidator.consolidate(uploadRequestResult)
- val localChange = database.getLocalChanges(ResourceType.Observation, "observation1").last()
- assertThat(
- (FhirContext.forR4Cached().newJsonParser().parseResource(localChange.payload)
- as Observation)
- .subject
- .reference,
- )
- .isEqualTo("Patient/patient2")
+ assertThat(database.getAllLocalChanges()).isEmpty()
}
}
diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt
index 39778cdba6..6ae329f53a 100644
--- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt
+++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt
@@ -65,7 +65,7 @@ interface FhirEngine : CrudFhirEngine {
* This function initiates multiple server calls to upload local changes. The results of each call
* are emitted as [UploadRequestResult] objects, which can be collected using a [Flow].
*
- * @param localChangesFetchMode Specifies how to fetch local changes for upload.
+ * @param uploadStrategy Defines strategies for uploading FHIR resource.
* @param upload A suspending function that takes a list of [LocalChange] objects and returns a
* [Flow] of [UploadRequestResult] objects.
* @return A [Flow] that emits the progress of the synchronization process as [SyncUploadProgress]
diff --git a/engine/src/main/java/com/google/android/fhir/MoreResources.kt b/engine/src/main/java/com/google/android/fhir/MoreResources.kt
index f444acbe19..e4e84a141d 100644
--- a/engine/src/main/java/com/google/android/fhir/MoreResources.kt
+++ b/engine/src/main/java/com/google/android/fhir/MoreResources.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022-2023 Google LLC
+ * Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,6 +17,10 @@
package com.google.android.fhir
import java.lang.reflect.InvocationTargetException
+import java.time.Instant
+import java.util.Date
+import org.hl7.fhir.r4.model.IdType
+import org.hl7.fhir.r4.model.InstantType
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
@@ -58,7 +62,20 @@ fun getResourceClass(resourceType: String): Class {
}
internal val Resource.versionId: String?
- get() = meta.versionId
+ get() = if (hasMeta()) meta.versionId else null
internal val Resource.lastUpdated
get() = if (hasMeta()) meta.lastUpdated?.toInstant() else null
+
+/**
+ * Updates the meta information of a FHIR [Resource] with the provided version ID and last updated
+ * timestamp. This extension function sets the version ID and last updated time in the resource's
+ * metadata. If the provided values are null, the respective fields in the meta will remain
+ * unchanged.
+ */
+internal fun Resource.updateMeta(versionId: String?, lastUpdatedRemote: Instant?) {
+ meta.apply {
+ versionId?.let { versionIdElement = IdType(it) }
+ lastUpdatedRemote?.let { lastUpdatedElement = InstantType(Date.from(it)) }
+ }
+}
diff --git a/engine/src/main/java/com/google/android/fhir/db/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt
index 251bf21b6c..7f321ae712 100644
--- a/engine/src/main/java/com/google/android/fhir/db/Database.kt
+++ b/engine/src/main/java/com/google/android/fhir/db/Database.kt
@@ -59,8 +59,20 @@ internal interface Database {
suspend fun updateVersionIdAndLastUpdated(
resourceId: String,
resourceType: ResourceType,
- versionId: String,
- lastUpdated: Instant,
+ versionId: String?,
+ lastUpdated: Instant?,
+ )
+
+ /**
+ * Updates the existing [oldResourceId] with the new [newResourceId]. Even if [oldResourceId] and
+ * [newResourceId] are the same, it is still necessary to update the resource meta.
+ */
+ suspend fun updateResourcePostSync(
+ oldResourceId: String,
+ newResourceId: String,
+ resourceType: ResourceType,
+ versionId: String?,
+ lastUpdated: Instant?,
)
/**
@@ -125,11 +137,13 @@ internal interface Database {
suspend fun deleteUpdates(resources: List)
/**
- * Updates the [ResourceEntity.serializedResource] and [ResourceEntity.resourceId] corresponding
- * to the updatedResource. Updates all the [LocalChangeEntity] for this updated resource as well
- * as all the [LocalChangeEntity] referring to this resource in their [LocalChangeEntity.payload]
- * Updates the [ResourceEntity.serializedResource] for all the resources which refer to this
- * updated resource.
+ * Updates the existing resource identified by [currentResourceId] with the [updatedResource],
+ * ensuring all associated references in the database are also updated accordingly.
+ *
+ * Implementations of this function should perform the following steps within a transaction:
+ * 1. Update the corresponding [ResourceEntity].
+ * 2. Update associated [LocalChangeEntity] records.
+ * 3. Update the serialized representation of referring resources.
*/
suspend fun updateResourceAndReferences(
currentResourceId: String,
diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt
index 7bf364f4e9..408c3a7054 100644
--- a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt
+++ b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt
@@ -33,13 +33,16 @@ import com.google.android.fhir.db.impl.DatabaseImpl.Companion.UNENCRYPTED_DATABA
import com.google.android.fhir.db.impl.dao.ForwardIncludeSearchResult
import com.google.android.fhir.db.impl.dao.LocalChangeDao.Companion.SQLITE_LIMIT_MAX_VARIABLE_NUMBER
import com.google.android.fhir.db.impl.dao.ReverseIncludeSearchResult
+import com.google.android.fhir.db.impl.entities.LocalChangeEntity
import com.google.android.fhir.db.impl.entities.ResourceEntity
import com.google.android.fhir.index.ResourceIndexer
import com.google.android.fhir.logicalId
import com.google.android.fhir.search.SearchQuery
import com.google.android.fhir.toLocalChange
+import com.google.android.fhir.updateMeta
import java.time.Instant
import java.util.UUID
+import org.hl7.fhir.r4.model.IdType
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
@@ -161,19 +164,38 @@ internal class DatabaseImpl(
override suspend fun updateVersionIdAndLastUpdated(
resourceId: String,
resourceType: ResourceType,
- versionId: String,
- lastUpdated: Instant,
+ versionId: String?,
+ lastUpdatedRemote: Instant?,
) {
db.withTransaction {
resourceDao.updateAndIndexRemoteVersionIdAndLastUpdate(
resourceId,
resourceType,
versionId,
- lastUpdated,
+ lastUpdatedRemote,
)
}
}
+ override suspend fun updateResourcePostSync(
+ oldResourceId: String,
+ newResourceId: String,
+ resourceType: ResourceType,
+ versionId: String?,
+ lastUpdatedRemote: Instant?,
+ ) {
+ db.withTransaction {
+ resourceDao.getResourceEntity(oldResourceId, resourceType)?.let { oldResourceEntity ->
+ val updatedResource =
+ (iParser.parseResource(oldResourceEntity.serializedResource) as Resource).apply {
+ idElement = IdType(newResourceId)
+ updateMeta(versionId, lastUpdatedRemote)
+ }
+ updateResourceAndReferences(oldResourceId, updatedResource)
+ }
+ }
+ }
+
override suspend fun select(type: ResourceType, id: String): Resource {
return resourceDao.getResource(resourceId = id, resourceType = type)?.let {
iParser.parseResource(it) as Resource
@@ -290,11 +312,28 @@ internal class DatabaseImpl(
val resourceUuid = currentResourceEntity.resourceUuid
updateResourceEntity(resourceUuid, updatedResource)
+ if (currentResourceId == updatedResource.logicalId) {
+ return@withTransaction
+ }
+
+ /**
+ * Update LocalChange records and identify referring resources.
+ *
+ * We need to update LocalChange records first because they might contain references to the
+ * old resource ID that are not readily searchable or present in the latest version of the
+ * [ResourceEntity] itself. The [LocalChangeResourceReferenceEntity] table helps us identify
+ * these [LocalChangeEntity] records accurately.
+ *
+ * Once LocalChange records are updated, we can then safely update the corresponding
+ * ResourceEntity records to ensure data consistency. Hence, we obtain the
+ * [ResourceEntity.resourceUuid]s of the resources from the updated LocalChangeEntity records
+ * and use them in the next step.
+ */
val uuidsOfReferringResources =
- updateLocalChangeResourceIdAndReferences(
+ localChangeDao.updateResourceIdAndReferences(
resourceUuid = resourceUuid,
oldResource = oldResource,
- updatedResource = updatedResource,
+ updatedResourceId = updatedResource.logicalId,
)
updateReferringResources(
@@ -312,25 +351,6 @@ internal class DatabaseImpl(
private suspend fun updateResourceEntity(resourceUuid: UUID, updatedResource: Resource) =
resourceDao.updateResourceWithUuid(resourceUuid, updatedResource)
- /**
- * Update the [LocalChange]s to reflect the change in the resource ID. This primarily includes
- * modifying the [LocalChange.resourceId] for the changes of the affected resource. Also, update
- * any references in the [LocalChange] which refer to the affected resource.
- *
- * The function returns a [List<[UUID]>] which corresponds to the [ResourceEntity.resourceUuid]
- * which contain references to the affected resource.
- */
- private suspend fun updateLocalChangeResourceIdAndReferences(
- resourceUuid: UUID,
- oldResource: Resource,
- updatedResource: Resource,
- ) =
- localChangeDao.updateResourceIdAndReferences(
- resourceUuid = resourceUuid,
- oldResource = oldResource,
- updatedResource = updatedResource,
- )
-
/**
* Update all [Resource] and their corresponding [ResourceEntity] which refer to the affected
* resource. The update of the references in the [Resource] is also expected to reflect in the
diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt
index 31be98d1e1..637c17b136 100644
--- a/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt
+++ b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 Google LLC
+ * Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt
index a078f80b65..68069b8ab7 100644
--- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt
+++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt
@@ -328,14 +328,14 @@ internal abstract class LocalChangeDao {
@Query(
"""
- SELECT *
+ SELECT DISTINCT localChangeId
FROM LocalChangeResourceReferenceEntity
WHERE resourceReferenceValue = :resourceReferenceValue
""",
)
- abstract suspend fun getLocalChangeReferencesWithValue(
+ abstract suspend fun getLocalChangeIdsWithReferenceValue(
resourceReferenceValue: String,
- ): List
+ ): List
@Query(
"""
@@ -365,21 +365,29 @@ internal abstract class LocalChangeDao {
)
/**
- * Updates the resource IDs of the [LocalChange] of the updated resource. Updates [LocalChange]
- * with references to the updated resource.
+ * Updates the [LocalChangeEntity]s to reflect the change in the resource ID.
+ *
+ * This function performs the following steps:
+ * 1. Updates the `resourceId` in `LocalChange` entities directly related to the updated resource
+ * 2. Updates references within `LocalChange` payloads that point to the updated resource
+ *
+ * @param resourceUuid The UUID of the resource whose ID has changed
+ * @param oldResource The original resource with the old ID
+ * @param updatedResourceId The updated resource ID
+ * @return A list of UUIDs representing resources that reference the affected resource
*/
suspend fun updateResourceIdAndReferences(
resourceUuid: UUID,
oldResource: Resource,
- updatedResource: Resource,
+ updatedResourceId: String,
): List {
updateResourceIdInResourceLocalChanges(
resourceUuid = resourceUuid,
- updatedResourceId = updatedResource.logicalId,
+ updatedResourceId = updatedResourceId,
)
return updateReferencesInLocalChange(
oldResource = oldResource,
- updatedResource = updatedResource,
+ updatedResourceId = updatedResourceId,
)
}
@@ -396,37 +404,51 @@ internal abstract class LocalChangeDao {
}
/**
- * Looks for [LocalChangeEntity] which refer to the updated resource through
- * [LocalChangeResourceReferenceEntity]. For each [LocalChangeEntity] which contains reference to
- * the updated resource in its payload, we update the payload with the reference and also update
- * the corresponding [LocalChangeResourceReferenceEntity]. We delete the original
- * [LocalChangeEntity] and create a new one with new [LocalChangeResourceReferenceEntity]s in its
- * place. This method returns a list of the [ResourceEntity.resourceUuid] for all the resources
- * whose [LocalChange] contained references to the oldResource
+ * Updates references within [LocalChangeEntity] payloads to reflect a resource ID change.
+ *
+ * This function performs the following steps:
+ * 1. Retrieves [LocalChangeEntity] records that reference the old resource.
+ * 2. For each [LocalChangeEntity]:
+ * - Replaces the old resource reference with the new one in its payload.
+ * - Creates updated [LocalChangeResourceReferenceEntity] objects.
+ * - Deletes the original [LocalChangeEntity] record, which triggers a cascading delete in
+ * [LocalChangeResourceReferenceEntity].
+ * - Creates a new [LocalChangeEntity] record along with new
+ * [LocalChangeResourceReferenceEntity] records.
+ *
+ * @param oldResource The original resource whose ID has been updated.
+ * @param updatedResource The updated resource with the new ID.
+ * @return A list of distinct resource UUIDs for all `LocalChangeEntity` records that referenced
+ * the old resource.
*/
- private suspend fun updateReferencesInLocalChange(
+ internal suspend fun updateReferencesInLocalChange(
oldResource: Resource,
- updatedResource: Resource,
+ updatedResourceId: String,
): List {
val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}"
- val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}"
- val referringLocalChangeIds =
- getLocalChangeReferencesWithValue(oldReferenceValue).map { it.localChangeId }.distinct()
- val referringLocalChanges =
+ val updatedReferenceValue = "${oldResource.resourceType.name}/$updatedResourceId"
+
+ /**
+ * [getLocalChangeIdsWithReferenceValue] and [getLocalChanges] cannot be combined due to a
+ * limitation in Room. Fetching [LocalChangeEntity] in chunks is required to avoid the error
+ * documented in https://github.com/google/android-fhir/issues/2559.
+ */
+ val referringLocalChangeIds = getLocalChangeIdsWithReferenceValue(oldReferenceValue)
+ val localChangeEntitiesWithOldReferences =
referringLocalChangeIds.chunked(SQLITE_LIMIT_MAX_VARIABLE_NUMBER).flatMap {
getLocalChanges(it)
}
- referringLocalChanges.forEach { existingLocalChangeEntity ->
+ localChangeEntitiesWithOldReferences.forEach { localChangeEntityWithOldReferences ->
val updatedLocalChangeEntity =
replaceReferencesInLocalChangePayload(
- localChange = existingLocalChangeEntity,
+ localChange = localChangeEntityWithOldReferences,
oldReference = oldReferenceValue,
updatedReference = updatedReferenceValue,
)
.copy(id = DEFAULT_ID_VALUE)
val updatedLocalChangeReferences =
- getReferencesForLocalChange(existingLocalChangeEntity.id).map {
+ getReferencesForLocalChange(localChangeEntityWithOldReferences.id).map {
localChangeResourceReferenceEntity ->
if (localChangeResourceReferenceEntity.resourceReferenceValue == oldReferenceValue) {
LocalChangeResourceReferenceEntity(
@@ -442,10 +464,10 @@ internal abstract class LocalChangeDao {
)
}
}
- discardLocalChanges(existingLocalChangeEntity.id)
+ discardLocalChanges(localChangeEntityWithOldReferences.id)
createLocalChange(updatedLocalChangeEntity, updatedLocalChangeReferences)
}
- return referringLocalChanges.map { it.resourceUuid }.distinct()
+ return localChangeEntitiesWithOldReferences.map { it.resourceUuid }.distinct()
}
private fun replaceReferencesInLocalChangePayload(
diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt
index 449277f31d..0f219d84dd 100644
--- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt
+++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt
@@ -37,11 +37,11 @@ import com.google.android.fhir.db.impl.entities.StringIndexEntity
import com.google.android.fhir.db.impl.entities.TokenIndexEntity
import com.google.android.fhir.db.impl.entities.UriIndexEntity
import com.google.android.fhir.index.ResourceIndexer
-import com.google.android.fhir.index.ResourceIndexer.Companion.createLastUpdatedIndex
import com.google.android.fhir.index.ResourceIndexer.Companion.createLocalLastUpdatedIndex
import com.google.android.fhir.index.ResourceIndices
import com.google.android.fhir.lastUpdated
import com.google.android.fhir.logicalId
+import com.google.android.fhir.updateMeta
import com.google.android.fhir.versionId
import java.time.Instant
import java.util.Date
@@ -87,8 +87,8 @@ internal abstract class ResourceDao {
it.copy(
resourceId = updatedResource.logicalId,
serializedResource = iParser.encodeResourceToString(updatedResource),
- lastUpdatedRemote = updatedResource.meta.lastUpdated?.toInstant() ?: it.lastUpdatedRemote,
- versionId = updatedResource.meta.versionId,
+ lastUpdatedRemote = updatedResource.lastUpdated ?: it.lastUpdatedRemote,
+ versionId = updatedResource.versionId ?: it.versionId,
)
updateChanges(entity, updatedResource)
}
@@ -181,8 +181,8 @@ internal abstract class ResourceDao {
abstract suspend fun updateRemoteVersionIdAndLastUpdate(
resourceId: String,
resourceType: ResourceType,
- versionId: String,
- lastUpdatedRemote: Instant,
+ versionId: String?,
+ lastUpdatedRemote: Instant?,
)
@Query(
@@ -293,21 +293,13 @@ internal abstract class ResourceDao {
suspend fun updateAndIndexRemoteVersionIdAndLastUpdate(
resourceId: String,
resourceType: ResourceType,
- versionId: String,
- lastUpdated: Instant,
+ versionId: String?,
+ lastUpdatedRemote: Instant?,
) {
- updateRemoteVersionIdAndLastUpdate(resourceId, resourceType, versionId, lastUpdated)
- // update the remote lastUpdated index
- getResourceEntity(resourceId, resourceType)?.let {
- val indicesToUpdate =
- ResourceIndices.Builder(resourceType, resourceId)
- .apply {
- addDateTimeIndex(
- createLastUpdatedIndex(resourceType, InstantType(Date.from(lastUpdated))),
- )
- }
- .build()
- updateIndicesForResource(indicesToUpdate, resourceType, it.resourceUuid)
+ getResourceEntity(resourceId, resourceType)?.let { oldResourceEntity ->
+ val resource = iParser.parseResource(oldResourceEntity.serializedResource) as Resource
+ resource.updateMeta(versionId, lastUpdatedRemote)
+ updateResourceWithUuid(oldResourceEntity.resourceUuid, resource)
}
}
diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt
index ea8870b5f5..e946096eef 100644
--- a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt
+++ b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt
@@ -18,10 +18,11 @@ package com.google.android.fhir.sync.upload
import com.google.android.fhir.LocalChangeToken
import com.google.android.fhir.db.Database
+import com.google.android.fhir.lastUpdated
import com.google.android.fhir.sync.upload.request.UploadRequestGeneratorMode
+import com.google.android.fhir.versionId
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.DomainResource
-import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
import org.hl7.fhir.r4.model.codesystems.HttpVerb
@@ -56,8 +57,12 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res
)
uploadRequestResult.successfulUploadResponseMappings.forEach {
when (it) {
- is BundleComponentUploadResponseMapping -> updateVersionIdAndLastUpdated(it.output)
- is ResourceUploadResponseMapping -> updateVersionIdAndLastUpdated(it.output)
+ is BundleComponentUploadResponseMapping -> {
+ updateResourceMeta(it.output)
+ }
+ is ResourceUploadResponseMapping -> {
+ updateResourceMeta(it.output)
+ }
}
}
}
@@ -68,52 +73,57 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res
}
}
- private suspend fun updateVersionIdAndLastUpdated(response: Bundle.BundleEntryResponseComponent) {
- if (response.hasEtag() && response.hasLastModified() && response.hasLocation()) {
- response.resourceIdAndType?.let { (id, type) ->
- database.updateVersionIdAndLastUpdated(
- id,
- type,
- getVersionFromETag(response.etag),
- response.lastModified.toInstant(),
- )
- }
- }
- }
-
- private suspend fun updateVersionIdAndLastUpdated(resource: DomainResource) {
- if (resource.hasMeta() && resource.meta.hasVersionId() && resource.meta.hasLastUpdated()) {
+ private suspend fun updateResourceMeta(response: Bundle.BundleEntryResponseComponent) {
+ response.resourceIdAndType?.let { (id, type) ->
database.updateVersionIdAndLastUpdated(
- resource.id,
- resource.resourceType,
- resource.meta.versionId,
- resource.meta.lastUpdated.toInstant(),
+ id,
+ type,
+ response.etag?.let { getVersionFromETag(response.etag) },
+ response.lastModified?.let { it.toInstant() },
)
}
}
+
+ private suspend fun updateResourceMeta(resource: DomainResource) {
+ database.updateVersionIdAndLastUpdated(
+ resource.id,
+ resource.resourceType,
+ resource.versionId,
+ resource.lastUpdated,
+ )
+ }
}
internal class HttpPostResourceConsolidator(private val database: Database) : ResourceConsolidator {
override suspend fun consolidate(uploadRequestResult: UploadRequestResult) =
when (uploadRequestResult) {
is UploadRequestResult.Success -> {
- database.deleteUpdates(
- LocalChangeToken(
- uploadRequestResult.successfulUploadResponseMappings.flatMap {
- it.localChanges.flatMap { localChange -> localChange.token.ids }
- },
- ),
- )
- uploadRequestResult.successfulUploadResponseMappings.forEach {
- when (it) {
+ uploadRequestResult.successfulUploadResponseMappings.forEach { responseMapping ->
+ when (responseMapping) {
is BundleComponentUploadResponseMapping -> {
- // TODO https://github.com/google/android-fhir/issues/2499
- throw NotImplementedError()
+ responseMapping.localChanges.firstOrNull()?.resourceId?.let { preSyncResourceId ->
+ database.deleteUpdates(
+ LocalChangeToken(
+ responseMapping.localChanges.flatMap { localChange -> localChange.token.ids },
+ ),
+ )
+ updateResourcePostSync(
+ preSyncResourceId,
+ responseMapping.output,
+ )
+ }
}
is ResourceUploadResponseMapping -> {
- val preSyncResourceId = it.localChanges.firstOrNull()?.resourceId
- preSyncResourceId?.let { preSyncResourceId ->
- updateResourcePostSync(preSyncResourceId, it.output)
+ database.deleteUpdates(
+ LocalChangeToken(
+ responseMapping.localChanges.flatMap { localChange -> localChange.token.ids },
+ ),
+ )
+ responseMapping.localChanges.firstOrNull()?.resourceId?.let { preSyncResourceId ->
+ database.updateResourceAndReferences(
+ preSyncResourceId,
+ responseMapping.output,
+ )
}
}
}
@@ -128,16 +138,15 @@ internal class HttpPostResourceConsolidator(private val database: Database) : Re
private suspend fun updateResourcePostSync(
preSyncResourceId: String,
- postSyncResource: Resource,
+ response: Bundle.BundleEntryResponseComponent,
) {
- if (
- postSyncResource.hasMeta() &&
- postSyncResource.meta.hasVersionId() &&
- postSyncResource.meta.hasLastUpdated()
- ) {
- database.updateResourceAndReferences(
+ response.resourceIdAndType?.let { (postSyncResourceID, resourceType) ->
+ database.updateResourcePostSync(
preSyncResourceId,
- postSyncResource,
+ postSyncResourceID,
+ resourceType,
+ response.etag?.let { getVersionFromETag(response.etag) },
+ response.lastModified?.let { response.lastModified.toInstant() },
)
}
}
@@ -165,7 +174,7 @@ private fun getVersionFromETag(eTag: String) =
* 1. absolute path: `///_history/`
* 2. relative path: `//_history/`
*/
-private val Bundle.BundleEntryResponseComponent.resourceIdAndType: Pair?
+internal val Bundle.BundleEntryResponseComponent.resourceIdAndType: Pair?
get() =
location
?.split("/")
diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt
index a926aac516..cb23ce6d3a 100644
--- a/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt
+++ b/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt
@@ -90,7 +90,7 @@ private constructor(
* Not yet implemented - Fetches all local changes, generates one patch per resource, and uploads
* them in a single bundled POST request.
*/
- private object AllChangesSquashedBundlePost :
+ object AllChangesSquashedBundlePost :
UploadStrategy(
LocalChangesFetchMode.AllChanges,
PatchGeneratorMode.PerResource,
diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/BundleEntryComponentGeneratorImplementations.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/BundleEntryComponentGeneratorImplementations.kt
index 28486b559d..9997b473d6 100644
--- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/BundleEntryComponentGeneratorImplementations.kt
+++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/BundleEntryComponentGeneratorImplementations.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 Google LLC
+ * Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -31,6 +31,13 @@ internal class HttpPutForCreateEntryComponentGenerator(useETagForUpload: Boolean
}
}
+internal class HttpPostForCreateEntryComponentGenerator(useETagForUpload: Boolean) :
+ BundleEntryComponentGenerator(Bundle.HTTPVerb.POST, useETagForUpload) {
+ override fun getEntryResource(patch: Patch): IBaseResource {
+ return FhirContext.forCached(FhirVersionEnum.R4).newJsonParser().parseResource(patch.payload)
+ }
+}
+
internal class HttpPatchForUpdateEntryComponentGenerator(useETagForUpload: Boolean) :
BundleEntryComponentGenerator(Bundle.HTTPVerb.PATCH, useETagForUpload) {
override fun getEntryResource(patch: Patch): IBaseResource {
diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt
index d622eee5d9..91735dd616 100644
--- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt
+++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt
@@ -97,6 +97,7 @@ internal class TransactionBundleGenerator(
private val createMapping =
mapOf(
Bundle.HTTPVerb.PUT to this::putForCreateBasedBundleComponentMapper,
+ Bundle.HTTPVerb.POST to this::postForCreateBasedBundleComponentMapper,
)
private val updateMapping =
@@ -143,6 +144,10 @@ internal class TransactionBundleGenerator(
useETagForUpload: Boolean,
): BundleEntryComponentGenerator = HttpPutForCreateEntryComponentGenerator(useETagForUpload)
+ private fun postForCreateBasedBundleComponentMapper(
+ useETagForUpload: Boolean,
+ ): BundleEntryComponentGenerator = HttpPostForCreateEntryComponentGenerator(useETagForUpload)
+
private fun patchForUpdateBasedBundleComponentMapper(
useETagForUpload: Boolean,
): BundleEntryComponentGenerator = HttpPatchForUpdateEntryComponentGenerator(useETagForUpload)
diff --git a/engine/src/test/java/com/google/android/fhir/MoreResourcesTest.kt b/engine/src/test/java/com/google/android/fhir/MoreResourcesTest.kt
index 9290dacedc..acec8ff664 100644
--- a/engine/src/test/java/com/google/android/fhir/MoreResourcesTest.kt
+++ b/engine/src/test/java/com/google/android/fhir/MoreResourcesTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,6 +18,10 @@ package com.google.android.fhir
import android.os.Build
import com.google.common.truth.Truth.assertThat
+import java.time.Instant
+import java.util.*
+import org.hl7.fhir.r4.model.InstantType
+import org.hl7.fhir.r4.model.Meta
import org.hl7.fhir.r4.model.Patient
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
@@ -43,4 +47,37 @@ class MoreResourcesTest {
fun `getResourceClass() by resource type should return resource class`() {
assertThat(getResourceClass(ResourceType.Patient)).isEqualTo(Patient::class.java)
}
+
+ @Test
+ fun `updateMeta should update resource meta with given versionId and lastUpdated`() {
+ val versionId = "1"
+ val instantValue = Instant.now()
+ val resource = Patient().apply { id = "patient" }
+
+ resource.updateMeta(versionId, instantValue)
+
+ assertThat(resource.meta.versionId).isEqualTo(versionId)
+ assertThat(resource.meta.lastUpdatedElement.value)
+ .isEqualTo(InstantType(Date.from(instantValue)).value)
+ }
+
+ @Test
+ fun `updateMeta should not change existing meta if new values are null`() {
+ val versionId = "1"
+ val instantValue = InstantType(Date.from(Instant.now()))
+ val resource =
+ Patient().apply {
+ id = "patient"
+ meta =
+ Meta().apply {
+ this.versionId = versionId
+ lastUpdatedElement = instantValue
+ }
+ }
+
+ resource.updateMeta(null, null)
+
+ assertThat(resource.meta.versionId).isEqualTo(versionId)
+ assertThat(resource.meta.lastUpdatedElement.value).isEqualTo(instantValue.value)
+ }
}
diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt
index 998ffcb4e5..a4050cb791 100644
--- a/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt
+++ b/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt
@@ -103,6 +103,23 @@ class TransactionBundleGeneratorTest {
}
}
+ @Test
+ fun `getGenerator() should not throw exception for create by POST`() = runBlocking {
+ val exception =
+ kotlin
+ .runCatching {
+ TransactionBundleGenerator.Factory.getGenerator(
+ Bundle.HTTPVerb.POST,
+ Bundle.HTTPVerb.PATCH,
+ generatedBundleSize = 500,
+ useETagForUpload = true,
+ )
+ }
+ .exceptionOrNull()
+
+ assert(exception !is IllegalArgumentException) { "IllegalArgumentException was thrown" }
+ }
+
@Test
fun `generate() should return Bundle Entry without if-match when useETagForUpload is false`() =
runBlocking {
@@ -259,22 +276,6 @@ class TransactionBundleGeneratorTest {
assertThat(exception.localizedMessage).isEqualTo("Creation using PATCH is not supported.")
}
- @Test
- fun `getGenerator() should through exception for create by POST`() {
- val exception =
- assertThrows(IllegalArgumentException::class.java) {
- runBlocking {
- TransactionBundleGenerator.Factory.getGenerator(
- Bundle.HTTPVerb.POST,
- Bundle.HTTPVerb.PATCH,
- generatedBundleSize = 500,
- useETagForUpload = true,
- )
- }
- }
- assertThat(exception.localizedMessage).isEqualTo("Creation using POST is not supported.")
- }
-
@Test
fun `getGenerator() should through exception for update by DELETE`() {
val exception =
diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt
index 3a3a956c6f..8066cda7d9 100644
--- a/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt
+++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt
@@ -167,6 +167,7 @@ internal constructor(
}
/** Loads resources from IGs listed in dependencies. */
+ @Deprecated("Load resources using URLs only")
suspend fun loadResources(
resourceType: String,
url: String? = null,
@@ -175,6 +176,7 @@ internal constructor(
version: String? = null,
): Iterable {
val resType = ResourceType.fromCode(resourceType)
+
val resourceEntities =
when {
url != null && version != null ->
@@ -189,6 +191,65 @@ internal constructor(
return resourceEntities.map { readMetadataResourceOrThrow(it.resourceFile)!! }
}
+ /**
+ * Loads knowledge artifact by its canonical URL and an optional version.
+ *
+ * The version can either be passed as a parameter or as part of the URL (using pipe `|` to
+ * separate the URL and the version). For example, passing the URL
+ * `http://abc.xyz/fhir/Library|1.0.0` with no version is the same as passing the URL
+ * `http://abc.xyz/fhir/Library` and version `1.0.0`.
+ *
+ * However, if a version is specified both as a parameter and as part of the URL, the two must
+ * match.
+ *
+ * @throws IllegalArgumentException if the url contains more than one pipe `|`
+ * @throws IllegalArgumentException if the version specified in the URL and the explicit version
+ * do not match
+ */
+ suspend fun loadResources(
+ url: String,
+ version: String? = null,
+ ): Iterable {
+ val (canonicalUrl, canonicalVersion) = canonicalizeUrlAndVersion(url, version ?: "")
+
+ val resourceEntities =
+ if (canonicalVersion == "") {
+ knowledgeDao.getResource(canonicalUrl)
+ } else {
+ listOfNotNull(knowledgeDao.getResource(canonicalUrl, canonicalVersion))
+ }
+ return resourceEntities.map { readMetadataResourceOrThrow(it.resourceFile) }
+ }
+
+ /**
+ * Canonicalizes the URL and version. It will extract the version as part of the URL separated by
+ * pipe `|`.
+ *
+ * For example, URL `http://abc.xyz/fhir/Library|1.0.0` will be canonicalized as URL
+ * `http://abc.xyz/fhir/Library` and version `1.0.0`.
+ *
+ * @throws IllegalArgumentException if the URL contains more than one pipe
+ * @throws IllegalArgumentException if the version specified in the URL and the explicit version
+ * do not match
+ */
+ private fun canonicalizeUrlAndVersion(
+ url: String,
+ version: String,
+ ): Pair {
+ if (!url.contains('|')) {
+ return Pair(url, version)
+ }
+
+ val parts = url.split('|')
+ require(parts.size == 2) { "URL $url contains too many parts separated by \"|\"" }
+
+ // If an explicit version is specified, it must match the one in the URL
+ require(version == "" || version == parts[1]) {
+ "Version specified in the URL $parts[1] and explicit version $version do not match"
+ }
+ return Pair(parts[0], parts[1])
+ }
+
/** Deletes Implementation Guide, cleans up files. */
suspend fun delete(vararg igDependencies: FhirNpmPackage) {
igDependencies.forEach { igDependency ->
diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/db/dao/KnowledgeDao.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/db/dao/KnowledgeDao.kt
index 204a39be50..c9d5cd3bd0 100644
--- a/knowledge/src/main/java/com/google/android/fhir/knowledge/db/dao/KnowledgeDao.kt
+++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/db/dao/KnowledgeDao.kt
@@ -99,6 +99,7 @@ abstract class KnowledgeDao {
url: String,
): ResourceMetadataEntity?
+ @Deprecated("Load resources using URLs")
@Query(
"SELECT * from ResourceMetadataEntity WHERE resourceType = :resourceType AND name = :name",
)
@@ -107,6 +108,17 @@ abstract class KnowledgeDao {
name: String?,
): List
+ @Query("SELECT * from ResourceMetadataEntity WHERE url = :url")
+ internal abstract suspend fun getResource(
+ url: String,
+ ): List
+
+ @Query("SELECT * from ResourceMetadataEntity WHERE url = :url AND version = :version")
+ internal abstract suspend fun getResource(
+ url: String,
+ version: String,
+ ): ResourceMetadataEntity
+
@Query(
"SELECT * from ResourceMetadataEntity WHERE resourceMetadataId = :id",
)
diff --git a/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt b/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt
index 48cdefd60f..c8a26d270e 100644
--- a/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt
+++ b/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt
@@ -99,20 +99,27 @@ internal class KnowledgeManagerTest {
fun `imported entries are readable`() = runTest {
knowledgeManager.import(fhirNpmPackage, dataFolder)
- assertThat(knowledgeManager.loadResources(resourceType = "Library", name = "WHOCommon"))
- .isNotNull()
- assertThat(knowledgeManager.loadResources(resourceType = "Library", url = "FHIRCommon"))
- .isNotNull()
- assertThat(knowledgeManager.loadResources(resourceType = "Measure")).hasSize(1)
assertThat(
knowledgeManager.loadResources(
- resourceType = "Measure",
+ url = "http://fhir.org/guides/who/anc-cds/Library/WHOCommon",
+ version = "0.3.0",
+ ),
+ )
+ .hasSize(1)
+ assertThat(
+ knowledgeManager.loadResources(
+ url = "http://fhir.org/guides/who/anc-cds/Library/FHIRCommon",
+ version = "0.3.0",
+ ),
+ )
+ .hasSize(1)
+ assertThat(
+ knowledgeManager.loadResources(
url = "http://fhir.org/guides/who/anc-cds/Measure/ANCIND01",
+ version = "0.3.0",
),
)
- .isNotEmpty()
- assertThat(knowledgeManager.loadResources(resourceType = "Measure", url = "Measure/ANCIND01"))
- .isNotNull()
+ .hasSize(1)
}
@Test
@@ -130,14 +137,14 @@ internal class KnowledgeManagerTest {
Library().apply {
id = "Library/defaultA-A.1.0.0"
name = "defaultA"
- url = "www.exampleA.com"
+ url = "www.exampleA.com/Library/defaultA-A.1.0.0"
version = "A.1.0.0"
}
val libraryANew =
Library().apply {
id = "Library/defaultA-A.1.0.1"
name = "defaultA"
- url = "www.exampleA.com"
+ url = "www.exampleA.com/Library/defaultA-A.1.0.1"
version = "A.1.0.1"
}
@@ -149,13 +156,13 @@ internal class KnowledgeManagerTest {
val resourceA100 =
knowledgeManager
- .loadResources(resourceType = "Library", name = "defaultA", version = "A.1.0.0")
+ .loadResources(url = "www.exampleA.com/Library/defaultA-A.1.0.0", version = "A.1.0.0")
.single() as Library
assertThat(resourceA100.version).isEqualTo("A.1.0.0")
val resourceA101 =
knowledgeManager
- .loadResources(resourceType = "Library", name = "defaultA", version = "A.1.0.1")
+ .loadResources(url = "www.exampleA.com/Library/defaultA-A.1.0.1", version = "A.1.0.1")
.single() as Library
assertThat(resourceA101.version.toString()).isEqualTo("A.1.0.1")
}
@@ -163,36 +170,42 @@ internal class KnowledgeManagerTest {
fun `installing from npmPackageManager`() = runTest {
knowledgeManager.install(fhirNpmPackage)
- assertThat(knowledgeManager.loadResources(resourceType = "Library", name = "WHOCommon"))
- .isNotNull()
- assertThat(knowledgeManager.loadResources(resourceType = "Library", url = "FHIRCommon"))
- .isNotNull()
- assertThat(knowledgeManager.loadResources(resourceType = "Measure")).hasSize(1)
assertThat(
knowledgeManager.loadResources(
- resourceType = "Measure",
+ url = "http://fhir.org/guides/who/anc-cds/Library/WHOCommon",
+ version = "0.3.0",
+ ),
+ )
+ .hasSize(1)
+ assertThat(
+ knowledgeManager.loadResources(
+ url = "http://fhir.org/guides/who/anc-cds/Library/FHIRCommon",
+ version = "0.3.0",
+ ),
+ )
+ .hasSize(1)
+ assertThat(
+ knowledgeManager.loadResources(
url = "http://fhir.org/guides/who/anc-cds/Measure/ANCIND01",
+ version = "0.3.0",
),
)
- .isNotEmpty()
- assertThat(knowledgeManager.loadResources(resourceType = "Measure", url = "Measure/ANCIND01"))
- .isNotNull()
+ .hasSize(1)
}
@Test
fun `for different resources with URL loading by URL should be correct`() = runTest {
- val commonUrl = "www.sample-url.com"
val libraryWithSameUrl =
Library().apply {
id = "Library/lId"
name = "LibraryName"
- url = commonUrl
+ url = "www.sample-url.com/Library/lId"
}
val planDefinitionWithSameUrl =
PlanDefinition().apply {
id = "PlanDefinition/pdId"
name = "PlanDefinitionName"
- url = commonUrl
+ url = "www.sample-url.com/PlanDefinition/pdId"
}
knowledgeManager.index(writeToFile(libraryWithSameUrl))
@@ -202,30 +215,29 @@ internal class KnowledgeManagerTest {
assertThat(resources).hasSize(2)
val libraryLoadedByUrl =
- knowledgeManager.loadResources(resourceType = "Library", url = commonUrl).single() as Library
+ knowledgeManager.loadResources(url = "www.sample-url.com/Library/lId").single() as Library
assertThat(libraryLoadedByUrl.name.toString()).isEqualTo("LibraryName")
val planDefinitionLoadedByUrl =
- knowledgeManager.loadResources(resourceType = "PlanDefinition", url = commonUrl).single()
+ knowledgeManager.loadResources(url = "www.sample-url.com/PlanDefinition/pdId").single()
as PlanDefinition
assertThat(planDefinitionLoadedByUrl.name.toString()).isEqualTo("PlanDefinitionName")
}
@Test
fun `for different resources with URL and Version loading by URL should be correct`() = runTest {
- val commonUrl = "www.sample-url.com"
val libraryWithSameUrl =
Library().apply {
id = "Library/lId"
name = "LibraryName"
- url = commonUrl
+ url = "www.sample-url.com/Library/lId"
version = "0"
}
val planDefinitionWithSameUrl =
PlanDefinition().apply {
id = "PlanDefinition/pdId"
name = "PlanDefinitionName"
- url = commonUrl
+ url = "www.sample-url.com/PlanDefinition/pdId"
version = "0"
}
@@ -236,11 +248,11 @@ internal class KnowledgeManagerTest {
assertThat(resources).hasSize(2)
val libraryLoadedByUrl =
- knowledgeManager.loadResources(resourceType = "Library", url = commonUrl).single() as Library
+ knowledgeManager.loadResources(url = "www.sample-url.com/Library/lId").single() as Library
assertThat(libraryLoadedByUrl.name.toString()).isEqualTo("LibraryName")
val planDefinitionLoadedByUrl =
- knowledgeManager.loadResources(resourceType = "PlanDefinition", url = commonUrl).single()
+ knowledgeManager.loadResources(url = "www.sample-url.com/PlanDefinition/pdId").single()
as PlanDefinition
assertThat(planDefinitionLoadedByUrl.name.toString()).isEqualTo("PlanDefinitionName")
}