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") }