diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 594599d64e..61d8afb074 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -101,7 +101,6 @@ object Dependencies { const val mockWebServer = "com.squareup.okhttp3:mockwebserver:${Versions.http}" const val jsonToolsPatch = "com.github.java-json-tools:json-patch:${Versions.jsonToolsPatch}" - const val material = "com.google.android.material:material:${Versions.material}" const val sqlcipher = "net.zetetic:android-database-sqlcipher:${Versions.sqlcipher}" const val timber = "com.jakewharton.timber:timber:${Versions.timber}" const val woodstox = "com.fasterxml.woodstox:woodstox-core:${Versions.woodstox}" @@ -143,7 +142,6 @@ object Dependencies { const val jsonToolsPatch = "1.13" const val jsonAssert = "1.5.1" - const val material = "1.9.0" const val retrofit = "2.9.0" const val gsonConverter = "2.1.0" const val sqlcipher = "4.5.4" diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index dae9dfcc8b..4a5a54d064 100644 --- a/buildSrc/src/main/kotlin/Releases.kt +++ b/buildSrc/src/main/kotlin/Releases.kt @@ -48,7 +48,7 @@ object Releases { object Engine : LibraryArtifact { override val artifactId = "engine" - override val version = "1.0.0" + override val version = "1.1.0" override val name = "Android FHIR Engine Library" } @@ -98,6 +98,12 @@ object Releases { const val versionCode = 1 const val versionName = "1.0" } + + object WorkflowDemo { + const val applicationId = "com.google.android.fhir.workflow.demo" + const val versionCode = 1 + const val versionName = "1.0" + } } fun Project.publishArtifact(artifact: LibraryArtifact) { diff --git a/catalog/build.gradle.kts b/catalog/build.gradle.kts index ff0693fd64..a89e6d68e6 100644 --- a/catalog/build.gradle.kts +++ b/catalog/build.gradle.kts @@ -46,7 +46,6 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) - implementation(Dependencies.material) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) @@ -54,6 +53,7 @@ dependencies { implementation(libs.androidx.navigation.fragment) implementation(libs.androidx.navigation.ui) implementation(libs.kotlin.stdlib) + implementation(libs.material) implementation(project(path = ":datacapture")) implementation(project(path = ":engine")) diff --git a/codelabs/datacapture/README.md b/codelabs/datacapture/README.md index e9a7f9e196..af44e32e18 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:1.0.0") - implementation("androidx.fragment:fragment-ktx:1.5.5") + implementation("com.google.android.fhir:data-capture:1.2.0") + implementation("androidx.fragment:fragment-ktx:1.6.0") } ``` @@ -150,11 +150,11 @@ Open `MainActivity.kt` and add the following code to the `MainActivity` class: ```kotlin // Step 2: Configure a QuestionnaireFragment -val questionnaireJsonString = getStringFromAssets("questionnaire.json") +questionnaireJsonString = getStringFromAssets("questionnaire.json") + +val questionnaireFragment = + QuestionnaireFragment.builder().setQuestionnaire(questionnaireJsonString!!).build() -val questionnaireParams = bundleOf( -QuestionnaireFragment.EXTRA_QUESTIONNAIRE_JSON_STRING to questionnaireJsonString -) ``` ### Step 3: Add the QuestionnaireFragment to the FragmentContainerView @@ -168,10 +168,10 @@ Add the following code to the `MainActivity` class: ```kotlin // Step 3: Add the QuestionnaireFragment to the FragmentContainerView if (savedInstanceState == null) { - supportFragmentManager.commit { - setReorderingAllowed(true) - add(R.id.fragment_container_view, args = questionnaireParams) - } + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.fragment_container_view, questionnaireFragment) + } } // Submit button callback supportFragmentManager.setFragmentResultListener( diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md index 67bfd6fabc..f0fc5b8ddf 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:1.0.0") + implementation("com.google.android.fhir:engine:1.1.0") } ``` @@ -257,7 +257,13 @@ outlined below will guide you through the process. override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) - override fun getUploadStrategy() = UploadStrategy.AllChangesSquashedBundlePut + override fun getUploadStrategy() = + UploadStrategy.forBundleRequest( + methodForCreate = HttpCreateMethod.PUT, + methodForUpdate = HttpUpdateMethod.PATCH, + squash = true, + bundleSize = 500, + ) } ``` diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts index 23c48e875f..b682babd86 100644 --- a/codelabs/engine/app/build.gradle.kts +++ b/codelabs/engine/app/build.gradle.kts @@ -49,6 +49,6 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - implementation("com.google.android.fhir:engine:1.0.0") + implementation("com.google.android.fhir:engine:1.1.0") implementation("androidx.fragment:fragment-ktx:1.8.3") } diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt index 409441019f..6b115142a0 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.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. @@ -30,8 +30,10 @@ class PatientItemViewHolder(binding: PatientListItemViewBinding) : fun bind(patientItem: Patient) { nameTextView.text = - patientItem.name.first().let { it.given.joinToString(separator = " ") + " " + it.family } - genderTextView.text = patientItem.gender.display + patientItem.name.firstOrNull()?.let { + it.given.joinToString(separator = " ") + " " + it.family + } + genderTextView.text = patientItem.gender?.display cityTextView.text = patientItem.address.singleOrNull()?.city } } diff --git a/contrib/barcode/build.gradle.kts b/contrib/barcode/build.gradle.kts index 426cd37624..d731f7819b 100644 --- a/contrib/barcode/build.gradle.kts +++ b/contrib/barcode/build.gradle.kts @@ -70,11 +70,11 @@ dependencies { implementation(Dependencies.Mlkit.barcodeScanning) implementation(Dependencies.Mlkit.objectDetection) implementation(Dependencies.Mlkit.objectDetectionCustom) - implementation(Dependencies.material) implementation(Dependencies.timber) implementation(libs.androidx.appcompat) implementation(libs.androidx.core) implementation(libs.androidx.fragment) + implementation(libs.material) testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.mockitoKotlin) diff --git a/contrib/locationwidget/build.gradle.kts b/contrib/locationwidget/build.gradle.kts index dc52ab95a7..0b2148f79c 100644 --- a/contrib/locationwidget/build.gradle.kts +++ b/contrib/locationwidget/build.gradle.kts @@ -65,12 +65,12 @@ dependencies { implementation(project(":datacapture")) implementation(Dependencies.playServicesLocation) - implementation(Dependencies.material) implementation(Dependencies.timber) implementation(libs.androidx.appcompat) implementation(libs.androidx.core) implementation(libs.androidx.fragment) implementation(libs.kotlinx.coroutines.playservices) + implementation(libs.material) coreLibraryDesugaring(Dependencies.desugarJdkLibs) diff --git a/contrib/locationwidget/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactoryInstrumentedTest.kt b/contrib/locationwidget/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactoryInstrumentedTest.kt index b91eec759f..bdf795eac5 100644 --- a/contrib/locationwidget/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactoryInstrumentedTest.kt +++ b/contrib/locationwidget/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactoryInstrumentedTest.kt @@ -27,6 +27,7 @@ import androidx.core.view.isVisible import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Questionnaire import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -55,4 +56,30 @@ class LocationGpsCoordinateViewHolderFactoryInstrumentedTest { ) .isEqualTo(InputType.TYPE_NULL) } + + @Test + fun matcherShouldReturnTrueForOriginalGpsCoordinateUrl() { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent() + questionnaireItem.addExtension( + LocationGpsCoordinateViewHolderFactory.PRIMARY_GPS_COORDINATE_EXTENSION_URL, + null, + ) + assertThat(LocationGpsCoordinateViewHolderFactory.matcher(questionnaireItem)).isTrue() + } + + @Test + fun matcherShouldReturnTrueForOldGpsCoordinateUrl() { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent() + questionnaireItem.addExtension( + LocationGpsCoordinateViewHolderFactory.GPS_COORDINATE_EXTENSION_URL, + null, + ) + assertThat(LocationGpsCoordinateViewHolderFactory.matcher(questionnaireItem)).isTrue() + } + + @Test + fun matcherShouldReturnFalseForNoGpsCoordinateUrl() { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent() + assertThat(LocationGpsCoordinateViewHolderFactory.matcher(questionnaireItem)).isFalse() + } } diff --git a/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactory.kt b/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactory.kt index 18769f6166..a12b87dc69 100644 --- a/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactory.kt +++ b/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactory.kt @@ -59,8 +59,11 @@ object LocationGpsCoordinateViewHolderFactory : header.context.tryUnwrapContext()?.apply { val gpsCoordinateExtensionValue = questionnaireViewItem.questionnaireItem - .getExtensionByUrl(GPS_COORDINATE_EXTENSION_URL) - .value as StringType + .getExtensionByUrl(PRIMARY_GPS_COORDINATE_EXTENSION_URL) + ?.value as? StringType + ?: questionnaireViewItem.questionnaireItem + .getExtensionByUrl(GPS_COORDINATE_EXTENSION_URL) + .value as StringType when (gpsCoordinateExtensionValue.valueAsString) { GPS_COORDINATE_EXTENSION_VALUE_LATITUDE -> { supportFragmentManager.setFragmentResultListener( @@ -148,9 +151,12 @@ object LocationGpsCoordinateViewHolderFactory : } fun matcher(questionnaireItem: Questionnaire.QuestionnaireItemComponent): Boolean { - return questionnaireItem.hasExtension(GPS_COORDINATE_EXTENSION_URL) + return questionnaireItem.hasExtension(PRIMARY_GPS_COORDINATE_EXTENSION_URL) || + questionnaireItem.hasExtension(GPS_COORDINATE_EXTENSION_URL) } + const val PRIMARY_GPS_COORDINATE_EXTENSION_URL = + "https://github.com/google/android-fhir/StructureDefinition/gps-coordinate" const val GPS_COORDINATE_EXTENSION_URL = "gps-coordinate" const val GPS_COORDINATE_EXTENSION_VALUE_LATITUDE = "latitude" const val GPS_COORDINATE_EXTENSION_VALUE_LONGITUDE = "longitude" diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts index 34d81ab04c..b3c1e12d92 100644 --- a/datacapture/build.gradle.kts +++ b/datacapture/build.gradle.kts @@ -90,7 +90,6 @@ dependencies { exclude(module = "commons-logging") exclude(module = "httpclient") } - implementation(Dependencies.material) implementation(Dependencies.timber) implementation(libs.android.fhir.common) implementation(libs.androidx.appcompat) @@ -101,6 +100,7 @@ dependencies { implementation(libs.glide) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) + implementation(libs.material) testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.mockitoKotlin) 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 35ca593755..056cd47aad 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 @@ -42,6 +42,7 @@ import com.google.android.fhir.datacapture.extensions.isHelpCode import com.google.android.fhir.datacapture.extensions.isHidden import com.google.android.fhir.datacapture.extensions.isPaginated import com.google.android.fhir.datacapture.extensions.isRepeatedGroup +import com.google.android.fhir.datacapture.extensions.launchTimestamp import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.extensions.maxValue import com.google.android.fhir.datacapture.extensions.maxValueCqfCalculatedValueExpression @@ -64,6 +65,7 @@ import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import java.util.Date import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -74,6 +76,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -160,6 +163,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat .forEach { questionnaireResponse.addItem(it.createQuestionnaireResponseItem()) } } } + // Add extension for questionnaire launch time stamp + questionnaireResponse.launchTimestamp = DateTimeType(Date()) questionnaireResponse.packRepeatedGroups(questionnaire) } @@ -475,6 +480,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) .map { it.copy() } unpackRepeatedGroups(this@QuestionnaireViewModel.questionnaire) + // Use authored as a submission time stamp + authored = Date() } } 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 fd4690ea26..84ffef9247 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 @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.extensions import android.content.Context import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.method.LinkMovementMethod import android.view.View.GONE import android.view.View.VISIBLE import android.widget.Button @@ -82,7 +83,10 @@ fun initHelpViews( } } } - helpTextView.updateTextAndVisibility(questionnaireItem.localizedHelpSpanned) + helpTextView.apply { + updateTextAndVisibility(questionnaireItem.localizedHelpSpanned) + movementMethod = LinkMovementMethod.getInstance() + } } /** diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt index 0d9b3cd194..e560798035 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt @@ -16,9 +16,14 @@ package com.google.android.fhir.datacapture.extensions +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +internal const val EXTENSION_LAST_LAUNCHED_TIMESTAMP: String = + "http://github.com/google-android/questionnaire-lastLaunched-timestamp" + /** Pre-order list of all questionnaire response items in the questionnaire. */ val QuestionnaireResponse.allItems: List get() = item.flatMap { it.descendant } @@ -146,3 +151,20 @@ private fun unpackRepeatedGroups( listOf(questionnaireResponseItem) } } + +/** + * Adds a launch timestamp extension to the Questionnaire Response. If the extension @see + * EXTENSION_LAUNCH_TIMESTAMP already exists, it updates its value; otherwise, it adds a new one. + */ +internal var QuestionnaireResponse.launchTimestamp: DateTimeType? + get() { + val extension = this.extension.firstOrNull { it.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + return extension?.value as? DateTimeType + } + set(value) { + extension.find { it.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP }?.setValue(value) + ?: run { + // Add a new extension if none exists + extension.add(Extension(EXTENSION_LAST_LAUNCHED_TIMESTAMP, value)) + } + } 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 da85cbac4e..e2d1872bc4 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 @@ -17,6 +17,7 @@ package com.google.android.fhir.datacapture.views import android.content.Context +import android.text.method.LinkMovementMethod import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout @@ -55,11 +56,17 @@ class GroupHeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(con helpCardStateChangedCallback = questionnaireViewItem.helpCardStateChangedCallback, ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - // CQF expression takes precedence over static question text - question.updateTextAndVisibility(questionnaireViewItem.questionText) - hint.updateTextAndVisibility( - questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(), - ) + question.apply { + // CQF expression takes precedence over static question text + updateTextAndVisibility(questionnaireViewItem.questionText) + movementMethod = LinkMovementMethod.getInstance() + } + hint.apply { + updateTextAndVisibility( + questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(), + ) + movementMethod = LinkMovementMethod.getInstance() + } visibility = getHeaderViewVisibility(prefix, question, hint) applyCustomOrDefaultStyle( questionnaireViewItem.questionnaireItem, 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 7e5e77231d..b723190448 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 @@ -17,6 +17,7 @@ package com.google.android.fhir.datacapture.views import android.content.Context +import android.text.method.LinkMovementMethod import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout @@ -55,13 +56,17 @@ class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(context, helpCardStateChangedCallback = questionnaireViewItem.helpCardStateChangedCallback, ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - // CQF expression takes precedence over static question text - question.updateTextAndVisibility( - appendAsteriskToQuestionText(question.context, questionnaireViewItem), - ) - hint.updateTextAndVisibility( - questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(), - ) + question.apply { + // CQF expression takes precedence over static question text + updateTextAndVisibility(appendAsteriskToQuestionText(question.context, questionnaireViewItem)) + movementMethod = LinkMovementMethod.getInstance() + } + hint.apply { + updateTextAndVisibility( + questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(), + ) + movementMethod = LinkMovementMethod.getInstance() + } // 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) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelParameterizedTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelParameterizedTest.kt index f6c62956a3..312ff4fd2a 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelParameterizedTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelParameterizedTest.kt @@ -29,6 +29,7 @@ import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_REVIEW_PAGE_FIRST +import com.google.android.fhir.datacapture.extensions.EXTENSION_LAST_LAUNCHED_TIMESTAMP import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication import com.google.common.truth.Truth.assertThat import java.io.File @@ -89,7 +90,7 @@ class QuestionnaireViewModelParameterizedTest( val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { this.questionnaire = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire" @@ -135,7 +136,12 @@ class QuestionnaireViewModelParameterizedTest( val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } private fun createQuestionnaireViewModel( @@ -187,6 +193,28 @@ class QuestionnaireViewModelParameterizedTest( .isEqualTo(printer.encodeResourceToString(expected)) } + /** + * Asserts that the `expected` and the `actual` Questionnaire Responses are equal ignoring the + * stamp values + */ + fun assertQuestionnaireResponseEqualsIgnoringTimestamps( + actual: QuestionnaireResponse, + expected: QuestionnaireResponse, + ) { + val actualResponseWithoutTimestamp = + actual.copy().apply { + extension.removeIf { ext -> ext.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + authored = null + } + val expectedResponseWithoutTimestamp = + expected.copy().apply { + extension.removeIf { ext -> ext.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + authored = null + } + assertThat(printer.encodeResourceToString(actualResponseWithoutTimestamp)) + .isEqualTo(printer.encodeResourceToString(expectedResponseWithoutTimestamp)) + } + @JvmStatic @Parameters fun parameters() = 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 e644e4c6d6..51e00eaa1d 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 @@ -48,6 +48,7 @@ import com.google.android.fhir.datacapture.extensions.EXTENSION_ENTRY_MODE_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_HIDDEN_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_LAST_LAUNCHED_TIMESTAMP import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.EXTENSION_VARIABLE_URL import com.google.android.fhir.datacapture.extensions.EntryMode @@ -187,7 +188,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { this.questionnaire = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire" @@ -213,7 +214,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -251,7 +252,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -324,7 +325,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -422,7 +423,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -497,7 +498,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -565,7 +571,7 @@ class QuestionnaireViewModelTest { } runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( createQuestionnaireViewModel( questionnaire, questionnaireResponse, @@ -651,7 +657,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(questionnaireResponse, viewModel.getQuestionnaireResponse()) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -742,7 +753,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -794,7 +810,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -853,7 +874,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -895,7 +921,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -1006,7 +1037,7 @@ class QuestionnaireViewModelTest { printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) as QuestionnaireResponse - assertResourceEquals(value, expectedResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps(value, expectedResponse) } } @@ -1134,7 +1165,7 @@ class QuestionnaireViewModelTest { printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) as QuestionnaireResponse - assertResourceEquals(value, expectedResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps(value, expectedResponse) } } @@ -3340,7 +3371,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3387,7 +3418,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3435,7 +3466,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3489,7 +3520,7 @@ class QuestionnaireViewModelTest { val viewModel = QuestionnaireViewModel(context, state) - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3534,7 +3565,7 @@ class QuestionnaireViewModelTest { val viewModel = QuestionnaireViewModel(context, state) - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3621,7 +3652,10 @@ class QuestionnaireViewModelTest { val viewModel = QuestionnaireViewModel(context, state) - assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) } @Test @@ -3787,7 +3821,7 @@ class QuestionnaireViewModelTest { // Clearing the answer disables question-2 that in turn disables question-3. items.first { it.questionnaireItem.linkId == "question-1" }.clearAnswer() - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { id = "a-questionnaire-response" @@ -3809,7 +3843,10 @@ class QuestionnaireViewModelTest { }, ) - assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) } } @@ -4180,7 +4217,7 @@ class QuestionnaireViewModelTest { printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) as QuestionnaireResponse - assertResourceEquals(value, expectedResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps(value, expectedResponse) } } @@ -4278,7 +4315,7 @@ class QuestionnaireViewModelTest { val expectedResponse = printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) as QuestionnaireResponse - assertResourceEquals(value, expectedResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps(value, expectedResponse) } } @@ -4339,7 +4376,10 @@ class QuestionnaireViewModelTest { }, ) - assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) } } @@ -4459,7 +4499,7 @@ class QuestionnaireViewModelTest { ) .inOrder() - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( actual = viewModel.getQuestionnaireResponse(), expected = QuestionnaireResponse().apply { @@ -4641,7 +4681,10 @@ class QuestionnaireViewModelTest { }, ) - assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) } } @@ -7517,6 +7560,28 @@ class QuestionnaireViewModelTest { assertThat(printer.encodeResourceToString(actual)) .isEqualTo(printer.encodeResourceToString(expected)) } + + /** + * Asserts that the `expected` and the `actual` Questionnaire Responses are equal ignoring the + * stamp values + */ + private fun assertQuestionnaireResponseEqualsIgnoringTimestamps( + actual: QuestionnaireResponse, + expected: QuestionnaireResponse, + ) { + val actualResponseWithoutTimestamp = + actual.copy().apply { + extension.removeIf { ext -> ext.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + authored = null + } + val expectedResponseWithoutTimestamp = + expected.copy().apply { + extension.removeIf { ext -> ext.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + authored = null + } + assertThat(printer.encodeResourceToString(actualResponseWithoutTimestamp)) + .isEqualTo(printer.encodeResourceToString(expectedResponseWithoutTimestamp)) + } } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponsesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponsesTest.kt index 236ed8796a..275d031f01 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponsesTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponsesTest.kt @@ -20,6 +20,7 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -810,4 +811,29 @@ class MoreQuestionnaireResponsesTest { assertThat(iParser.encodeResourceToString(actual)) .isEqualTo(iParser.encodeResourceToString(expected)) } + + @Test + fun `should add launchTimestamp`() { + val questionnaireResponse = QuestionnaireResponse() + val dateTimeType = DateTimeType("2024-07-05T00:00:00Z") + questionnaireResponse.launchTimestamp = dateTimeType + + assertThat(dateTimeType).isEqualTo(questionnaireResponse.launchTimestamp) + } + + @Test + fun `launchTimestamp should be null when not added`() { + assertThat(QuestionnaireResponse().launchTimestamp).isNull() + } + + @Test + fun `launchTimestamp should update if already exists`() { + val questionnaireResponse = QuestionnaireResponse() + val oldDateTimeType = DateTimeType("2024-07-01T00:00:00Z") + val newDateTimeType = DateTimeType("2024-07-05T00:00:00Z") + questionnaireResponse.launchTimestamp = oldDateTimeType + questionnaireResponse.launchTimestamp = newDateTimeType + + assertThat(newDateTimeType).isEqualTo(questionnaireResponse.launchTimestamp) + } } diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 7d0fb5c13c..18c4cd7f1f 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -43,7 +43,6 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) - implementation(Dependencies.material) implementation(Dependencies.timber) implementation(libs.androidx.activity) implementation(libs.androidx.appcompat) @@ -60,6 +59,7 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) + implementation(libs.material) implementation(project(":datacapture")) { exclude(group = "com.google.android.fhir", module = "engine") } diff --git a/docs/use/WFL/Demo-app.md b/docs/use/WFL/Demo-app.md new file mode 100644 index 0000000000..f0deacdd85 --- /dev/null +++ b/docs/use/WFL/Demo-app.md @@ -0,0 +1,20 @@ +# Demo App + +The *Workflow Demo* app demonstrates the capabilities of CarePlan Generation API and the Activity Flow API. The app has a Patient card in the top showing the details and a carousel in the bottom with each card representing a particular phase of the activity flow. + +To run this app in Android Studio, [create a run/debug configuration](https://developer.android.com/studio/run/rundebugconfig) for the `workflow_demo` module using the [Android App](https://developer.android.com/studio/run/rundebugconfig#android-application) template and run the app using the configuration. + +Alternatively, run the following command to build and install the debug APK on your device/emulator: + +```shell +./gradlew :workflow_demo:installDebug +``` + +## Instructions +1. Click on the **Initialize** button to install the required dependencies for an activity flow. The dependencies are already bundled in the assets folder of the workflow demo app. After the dependencies are successfully installed, **Start** Button becomes enabled in the _Proposal_ card. +2. Now, click on the **Start** to generate a CarePlan which intern has a _Proposal_ Resource. This resource is then used by the app to create a new Activity Flow and the _Proposal_ card now shows the details of the resource with the **Start** button disabled now. The carousel auto moves to the next Phase Card i.e. _Plan_. +3. Repeat step 2 to move forward through the phases. +4. To restart the Activity click **Restart** Flow that removes all the resources related to the flow and moves the app back to step 2. +5. The overflow menu on the action bar may be used to switch between various Activities supported in the demo app. + +![Workflow Demo](workflow_demo_app.gif) diff --git a/docs/use/WFL/Run-an-Activity-Flow.md b/docs/use/WFL/Run-an-Activity-Flow.md new file mode 100644 index 0000000000..ff2731af40 --- /dev/null +++ b/docs/use/WFL/Run-an-Activity-Flow.md @@ -0,0 +1,128 @@ +# ActivityFlow + +![Activity Flow](activity_flow.svg) + +The `ActivityFlow` class manages the workflow of clinical recommendations according to the [FHIR Clinical Practice Guidelines (CPG) specification](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-lifecycle---request-phases-proposal-plan-order). It implements an activity flow as defined in the FHIR CPG IG, allowing you to guide clinical recommendations through various phases (proposal, plan, order, perform). + +You can start new workflows with an appropriate request resource from the generated [CarePlan](Generate-A-Care-Plan.md) or resume existing ones from any phase. + +**Important Considerations:** + +* **Thread Safety:** The `ActivityFlow` is not thread-safe. Concurrent changes from multiple threads may lead to unexpected behavior. +* **Blocking Operations:** Some methods of `ActivityFlow` and its associated `Phase` interface may block the caller thread. Ensure these are called from a worker thread to avoid UI freezes. + +## Creating an ActivityFlow + +Use the appropriate `ActivityFlow.of()` factory function to create an instance. You can start anew flow with a `CPGRequestResource` or resume an existing flow from a `CPGRequestResource` or `CPGEventResource` based on the current state. + +**Example:** +```kotlin +val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) +val request = CPGMedicationRequest( medicationRequestGeneratedByCarePlan) +val flow = ActivityFlow.of(repository, request) +``` + +## Navigating phases + +An `ActivityFlow` progresses through a series of phases, represented by the `Phase` class. Access the current phase using `getCurrentPhase()`. + +**Example:** +```kotlin + when (val phase = flow.getCurrentPhase( )) { + is Phase.ProposalPhase -> { /* Handle proposal phase */ } + is Phase.PlanPhase -> { /* Handle plan phase */ } + // ... other phases +} +``` + +## Transitioning between the phases + +`ActivityFlow` provides functions to prepare and initiate the next phase: + +* **`prepare...()`:** Creates a new request or event for the next phase without persisting changes. +* **`initiate...()`:** Creates a new phase based on the provided request/event and persists changes to the repository. + +**Example:** +```kotlin +val preparedPlan = flow.preparePlan().getOrThrow( ) +// ... modify preparedPlan +val planPhase = flow.initiatePlan(preparedPlan).getOrThrow( ) +``` + +## Transitioning to Perform Phase + +The `preparePerform()` function requires the event type as a parameter since the perform phase can create different event resources. + +**Example:** +```kotlin +val preparedPerformEvent = flow.preparePerform( CPGMedicationDispenseEvent::class.java).getOrThrow() + // ... update preparedPerformEvent + val performPhase = flow.initiatePerform( preparedPerformEvent). getOrThrow( ) +``` + +## Updating states in a phase + +* **`RequestPhase`:** (`ProposalPhase`, `PlanPhase`, `OrderPhase`) allows updating the request state using `update()`. +```kotlin +proposalPhase.update( + proposalPhase.getRequestResource().apply { setStatus(Status.ACTIVE) } +) +``` +* **`EventPhase`:** (`PerformPhase`) allows updating the event state using `update()` and completing the phase using `complete()`. +```kotlin +performPhase.update( + performPhase.getEventResource().apply { setStatus(EventStatus.COMPLETED) } +) +``` +## API List +### Factory functions + +* `ActivityFlow.of(...)`: Various overloads for creating `ActivityFlow` instances with different resource types. Refer to the code for specific usage. + +### Phase transition API + +* `preparePlan()`: Prepares a plan resource. +* `initiatePlan(...)`: Initiates the plan phase. +* `prepareOrder()`: Prepares an order resource. +* `initiateOrder(...)`: Initiates the order phase. +* `preparePerform(...)`: Prepares an event resource for the perform phase. +* `initiatePerform(...)`: Initiates the perform phase. + +### Other API +* `getCurrentPhase()`: Returns the current `Phase` of the workflow. + +### Request phase API + +* `getRequestResource()`: Returns a copy of resource. +* `update(..)`: Updates the resource. +* `suspend(..)`: Suspends the phase. +* `resume(..)`: Resumes the phase. +* `enteredInError(..)`: Marks the request entered-in-error. +* `reject(..)`: Rejects the phase. + +### Event phase API + +* `getEventResource()`: Returns a copy of resource. +* `update(..)`: Updates the resource. +* `suspend(..)`: Suspends the phase. +* `resume(..)`: Resumes the phase. +* `enteredInError(..)`: Marks the event entered-in-error. +* `start(..)`: Starts the event. +* `notDone(..)`: Marks the event not-done. +* `stop(..)`: Stop the event. +* `complete(..)`: Marks the event as complete. + + +## Supported activities +The library currently doesn't implement all of the activities outlined in the [activity profiles](https://build.fhir.org/ig/HL7/cqf-recommendations/profiles.html#activity-profiles). New activities may be added as per the requirement from the application developers. + +| Activity | Request | Event | +|--------------------|-------------------------|-----------------------| +| Send a message | CPGCommunicationRequest | CPGCommunication | +| Order a medication | CPGMedicationRequest | CPGMedicationDispense | + +## Additional resources + +* [FHIR Clinical Practice Guidelines IG](https://build.fhir.org/ig/HL7/cqf-recommendations/) +* [Activity Flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-flow) +* [Activity Profiles](https://build.fhir.org/ig/HL7/cqf-recommendations/profiles.html#activity-profiles) \ No newline at end of file diff --git a/docs/use/WFL/activity_flow.svg b/docs/use/WFL/activity_flow.svg new file mode 100644 index 0000000000..dac4eda98f --- /dev/null +++ b/docs/use/WFL/activity_flow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/use/WFL/index.md b/docs/use/WFL/index.md index 3681c450ed..da883ad9c3 100644 --- a/docs/use/WFL/index.md +++ b/docs/use/WFL/index.md @@ -6,6 +6,8 @@ The [Workflow](https://build.fhir.org/workflow.html) Library provides decision s 1. The [PlanDefinition](https://build.fhir.org/plandefinition.html) resource describes a plan or protocol for the care of a given patient. This could include a treatment plan for a specific condition, a discharge plan for a hospitalized patient, or a care plan for managing chronic illness. The output of this operation will be a CarePlan resource, which represents the plan that has been tailored and applied to the specific patient or group of patients. The CarePlan resource will include details about the actions that are part of the plan, the timing and frequency of those actions, and any relevant supporting information. It may also include references to other resources that are relevant to the plan, such as observations or procedures that are part of the plan. The Apply operator can be used to determine the specific actions or interventions that should be taken as part of a care plan, based on the patient's current status and other relevant factors. For example, it could be used to determine which medications a patient should be prescribed or to identify any necessary referrals to other healthcare providers. +1. The [Activity Flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-flow) is based on the workflow module in FHIR. It is used to create activities for the request resources generated in a CarePlan and take them through the various phases(proposal, plan, order, and perform) of the activity lifecycle. + 1. The [Library](https://build.fhir.org/library.html) resource describes a container for clinical knowledge assets. One of these assets is a shareable library of clinical logic, written in Clinical Quality Language (CQL). Users of the Workflow library can call an evaluation operator directly from the Library resource and run individual expressions at will. The output will be Parameters resource with the results of each expression evaluated. This operator should be used when the use case does not fit into a PlanDefinition or a Measure Evaluate. It's recommended that these 3 types of resources are authored within the scope of a [FHIR IG](https://www.hl7.org/fhir/implementationguide.html). The IG can then be published online and imported by the Android SDK. To import an IG, Android SDK users must simply copy the required files from the IG package into the `assets` folder and parse those files using the regular FHIR Parser. @@ -14,14 +16,17 @@ The workflow library is dependent on the [Engine library](../FEL/index.md). Oper Future features of the library will provide support for Tasking and other Workflow related requirements -## Next Steps +## Next steps * [Getting Started](Getting-Started.md) -* Guides +* Workflow Guides * [Generate a Care Plan](Generate-A-Care-Plan.md) + * [Run an Activity Flow](Run-an-Activity-Flow.md) * [Evaluate a Measure](Evaluate-a-Measure.md) +* Other Operations * [Evaluate a Library](Evaluate-a-Library.md) * [Compile CQL](Compile-and-Execute-CQL.md) + ## Data safety diff --git a/docs/use/WFL/workflow_demo_app.gif b/docs/use/WFL/workflow_demo_app.gif new file mode 100644 index 0000000000..f4f88fea67 Binary files /dev/null and b/docs/use/WFL/workflow_demo_app.gif differ diff --git a/docs/use/api.md b/docs/use/api.md index 3bd092acf8..84f73cc8d8 100644 --- a/docs/use/api.md +++ b/docs/use/api.md @@ -1,6 +1,6 @@ # API -* [Engine](api/engine/1.0.0/index.html) +* [Engine](api/engine/1.1.0/index.html) * [Data Capture](api/data-capture/1.2.0/index.html) * [Workflow](api/workflow/0.1.0-beta01/index.html) * [Knowledge](api/knowledge/0.1.0-beta01/index.html) diff --git a/docs/use/extensions.md b/docs/use/extensions.md index 77f89e0937..df009d5d64 100644 --- a/docs/use/extensions.md +++ b/docs/use/extensions.md @@ -5,3 +5,7 @@ This page lists [FHIR Extensions](http://hl7.org/fhir/extensibility.html) define * Dialog extension (https://github.com/google/android-fhir/StructureDefinition/dialog) This extension can only be used if the questionnaire item type is `choice` and has an item-control of type `check-box` or `radio-button`. + +* GPS Coordinate URL extension (https://github.com/google/android-fhir/StructureDefinition/gps-coordinate) + + This URL extension can only be used if the questionnaire item type is `decimal` and has a valueString `latitude` or `longitude` . \ No newline at end of file diff --git a/document/build.gradle.kts b/document/build.gradle.kts index 4190bcd57f..432f731448 100644 --- a/document/build.gradle.kts +++ b/document/build.gradle.kts @@ -38,7 +38,6 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) - implementation(Dependencies.material) implementation(Dependencies.Retrofit.coreRetrofit) implementation(Dependencies.Retrofit.gsonConverter) implementation(Dependencies.httpInterceptor) @@ -48,6 +47,7 @@ dependencies { implementation(libs.android.fhir.engine) implementation(libs.androidx.appcompat) implementation(libs.androidx.core) + implementation(libs.material) testImplementation(Dependencies.robolectric) testImplementation(Dependencies.mockitoKotlin) diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/9.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/9.json new file mode 100644 index 0000000000..0478221de1 --- /dev/null +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/9.json @@ -0,0 +1,1024 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "79670df517ddfc83ca717c035a38c798", + "entities": [ + { + "tableName": "ResourceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `serializedResource` TEXT NOT NULL, `versionId` TEXT, `lastUpdatedRemote` INTEGER, `lastUpdatedLocal` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceId", + "columnName": "resourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedResource", + "columnName": "serializedResource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdatedRemote", + "columnName": "lastUpdatedRemote", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdatedLocal", + "columnName": "lastUpdatedLocal", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ResourceEntity_resourceUuid", + "unique": true, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ResourceEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + }, + { + "name": "index_ResourceEntity_resourceType_resourceId", + "unique": true, + "columnNames": [ + "resourceType", + "resourceId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ResourceEntity_resourceType_resourceId` ON `${TABLE_NAME}` (`resourceType`, `resourceId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "StringIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_StringIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StringIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_StringIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StringIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "ReferenceIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ReferenceIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ReferenceIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_ReferenceIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ReferenceIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "TokenIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_system` TEXT, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.system", + "columnName": "index_system", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TokenIndexEntity_resourceType_index_name_index_value_resourceUuid", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value", + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TokenIndexEntity_resourceType_index_name_index_value_resourceUuid` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`, `resourceUuid`)" + }, + { + "name": "index_TokenIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TokenIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "QuantityIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_system` TEXT NOT NULL, `index_code` TEXT NOT NULL, `index_value` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.system", + "columnName": "index_system", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.code", + "columnName": "index_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_QuantityIndexEntity_resourceType_index_name_index_value_index_code", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value", + "index_code" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuantityIndexEntity_resourceType_index_name_index_value_index_code` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`, `index_code`)" + }, + { + "name": "index_QuantityIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuantityIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "UriIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UriIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UriIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_UriIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UriIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "DateIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_from` INTEGER NOT NULL, `index_to` INTEGER NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.from", + "columnName": "index_from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index.to", + "columnName": "index_to", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DateIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "resourceUuid", + "index_from", + "index_to" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `resourceUuid`, `index_from`, `index_to`)" + }, + { + "name": "index_DateIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "DateTimeIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_from` INTEGER NOT NULL, `index_to` INTEGER NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.from", + "columnName": "index_from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index.to", + "columnName": "index_to", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DateTimeIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "resourceUuid", + "index_from", + "index_to" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateTimeIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `resourceUuid`, `index_from`, `index_to`)" + }, + { + "name": "index_DateTimeIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateTimeIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "NumberIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NumberIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NumberIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_NumberIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NumberIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "LocalChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceUuid` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceId", + "columnName": "resourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LocalChangeEntity_resourceType_resourceId", + "unique": false, + "columnNames": [ + "resourceType", + "resourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `${TABLE_NAME}` (`resourceType`, `resourceId`)" + }, + { + "name": "index_LocalChangeEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PositionIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_latitude` REAL NOT NULL, `index_longitude` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.latitude", + "columnName": "index_latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "index.longitude", + "columnName": "index_longitude", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PositionIndexEntity_resourceType_index_latitude_index_longitude", + "unique": false, + "columnNames": [ + "resourceType", + "index_latitude", + "index_longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PositionIndexEntity_resourceType_index_latitude_index_longitude` ON `${TABLE_NAME}` (`resourceType`, `index_latitude`, `index_longitude`)" + }, + { + "name": "index_PositionIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PositionIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "LocalChangeResourceReferenceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferencePath` TEXT, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localChangeId", + "columnName": "localChangeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceReferenceValue", + "columnName": "resourceReferenceValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceReferencePath", + "columnName": "resourceReferencePath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LocalChangeResourceReferenceEntity_resourceReferenceValue", + "unique": false, + "columnNames": [ + "resourceReferenceValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_resourceReferenceValue` ON `${TABLE_NAME}` (`resourceReferenceValue`)" + }, + { + "name": "index_LocalChangeResourceReferenceEntity_localChangeId", + "unique": false, + "columnNames": [ + "localChangeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_localChangeId` ON `${TABLE_NAME}` (`localChangeId`)" + } + ], + "foreignKeys": [ + { + "table": "LocalChangeEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "localChangeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '79670df517ddfc83ca717c035a38c798')" + ] + } +} \ No newline at end of file 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 cd568b023a..1807b4b2d6 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 @@ -118,6 +118,7 @@ class DatabaseImplTest { @JvmField @Parameterized.Parameter(0) var encrypted: Boolean = false private val context: Context = ApplicationProvider.getApplicationContext() + private val parser = FhirContext.forR4Cached().newJsonParser() private lateinit var services: FhirServices private lateinit var database: Database @@ -202,7 +203,7 @@ class DatabaseImplTest { fun getLocalChanges_withSingleLocaleChange_shouldReturnSingleLocalChanges() = runBlocking { val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") database.insert(patient) - val patientString = services.parser.encodeResourceToString(patient) + val patientString = parser.encodeResourceToString(patient) val resourceLocalChanges = database.getLocalChanges(patient.resourceType, patient.logicalId) assertThat(resourceLocalChanges.size).isEqualTo(1) with(resourceLocalChanges[0]) { @@ -269,7 +270,7 @@ class DatabaseImplTest { fun clearDatabase_shouldClearAllTablesData() = runBlocking { val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") database.insert(patient) - val patientString = services.parser.encodeResourceToString(patient) + val patientString = parser.encodeResourceToString(patient) val resourceLocalChanges = database.getLocalChanges(patient.resourceType, patient.logicalId) assertThat(resourceLocalChanges.size).isEqualTo(1) with(resourceLocalChanges[0]) { @@ -393,7 +394,7 @@ class DatabaseImplTest { @Test fun insert_shouldAddInsertLocalChange() = runBlocking { - val testPatient2String = services.parser.encodeResourceToString(TEST_PATIENT_2) + val testPatient2String = parser.encodeResourceToString(TEST_PATIENT_2) database.insert(TEST_PATIENT_2) val resourceLocalChanges = database.getAllLocalChanges().filter { it.resourceId.equals(TEST_PATIENT_2_ID) } @@ -481,7 +482,7 @@ class DatabaseImplTest { database.insert(patient) patient = readFromFile(Patient::class.java, "/update_test_patient_1.json") database.update(patient) - services.parser.encodeResourceToString(patient) + parser.encodeResourceToString(patient) val localChangeTokenIds = database .getAllLocalChanges() @@ -4101,7 +4102,7 @@ class DatabaseImplTest { val observationLocalChange = updatedObservationLocalChanges[0] assertThat(observationLocalChange.type).isEqualTo(LocalChange.Type.INSERT) val observationLocalChangePayload = - services.parser.parseResource(observationLocalChange.payload) as Observation + parser.parseResource(observationLocalChange.payload) as Observation assertThat(observationLocalChangePayload.subject.reference) .isEqualTo("Patient/$remotelyCreatedPatientResourceId") } diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/EncryptedDatabaseErrorTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/EncryptedDatabaseErrorTest.kt index 412da27b45..76c359596f 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/EncryptedDatabaseErrorTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/EncryptedDatabaseErrorTest.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. @@ -22,7 +22,6 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.util.FhirTerser import com.google.android.fhir.DatabaseErrorStrategy.RECREATE_AT_OPEN import com.google.android.fhir.DatabaseErrorStrategy.UNSPECIFIED @@ -49,8 +48,7 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class EncryptedDatabaseErrorTest { private val context: Context = ApplicationProvider.getApplicationContext() - private val parser = FhirContext.forR4().newJsonParser() - private val terser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) + private val terser = FhirTerser(FhirContext.forR4Cached()) private val resourceIndexer = ResourceIndexer(SearchParamDefinitionsProviderImpl()) @After @@ -66,7 +64,6 @@ class EncryptedDatabaseErrorTest { // GIVEN an unencrypted database. DatabaseImpl( context, - parser, terser, DatabaseConfig( inMemory = false, @@ -84,7 +81,6 @@ class EncryptedDatabaseErrorTest { // THEN it should throw SQLiteException DatabaseImpl( context, - parser, terser, DatabaseConfig( inMemory = false, @@ -115,7 +111,6 @@ class EncryptedDatabaseErrorTest { // GIVEN an unencrypted database. DatabaseImpl( context, - parser, terser, DatabaseConfig( inMemory = false, @@ -139,7 +134,6 @@ class EncryptedDatabaseErrorTest { // THEN it should throw SQLiteException DatabaseImpl( context, - parser, terser, DatabaseConfig( inMemory = false, @@ -169,7 +163,6 @@ class EncryptedDatabaseErrorTest { // GIVEN an unencrypted database. DatabaseImpl( context, - parser, terser, DatabaseConfig( inMemory = false, @@ -193,7 +186,6 @@ class EncryptedDatabaseErrorTest { // THEN it should recreate the database DatabaseImpl( context, - parser, terser, DatabaseConfig( inMemory = false, @@ -226,7 +218,6 @@ class EncryptedDatabaseErrorTest { // GIVEN an encrypted database. DatabaseImpl( context, - parser, terser, DatabaseConfig( inMemory = false, @@ -244,7 +235,6 @@ class EncryptedDatabaseErrorTest { // THEN it should recreate database. DatabaseImpl( context, - parser, terser, DatabaseConfig( inMemory = false, diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index 56487f5ecb..01b9312cf2 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.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. @@ -369,6 +369,55 @@ class ResourceDatabaseMigrationTest { assertThat(localChangeReferences[localChange2Id]!!).containsExactly("Practitioner/345") } + @Test + fun migrate8To9_should_execute_with_no_exception(): Unit = runBlocking { + val taskId = "bed-net-001" + val taskResourceUuid = "8593abf6-b8dd-44d7-a35f-1c8843bc2c45" + val date = Date() + val bedNetTask = + Task() + .apply { + id = taskId + status = Task.TaskStatus.READY + meta.lastUpdated = date + } + .let { iParser.encodeResourceToString(it) } + + helper.createDatabase(DB_NAME, 8).apply { + execSQL( + "INSERT INTO ResourceEntity (resourceUuid, resourceType, resourceId, serializedResource, lastUpdatedLocal) VALUES ('$taskResourceUuid', 'Task', '$taskId', '$bedNetTask', '${DbTypeConverters.instantToLong(date.toInstant())}' );", + ) + execSQL( + "INSERT INTO TokenIndexEntity (resourceUuid, resourceType, index_name, index_path, index_system, index_value) VALUES ('$taskResourceUuid', 'Task', 'status', 'Task.status', 'http://hl7.org/fhir/task-status', 'ready');", + ) + close() + } + + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 9, true, Migration_8_9) + + val retrievedTask: String? + migratedDatabase.let { database -> + database + .query( + """ + SELECT a.serializedResource FROM ResourceEntity a + WHERE a.resourceType = 'Task' + AND a.resourceUuid IN (SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = 'Task' AND index_name = 'status' AND index_value = 'ready' + AND IFNULL(index_system, '') = 'http://hl7.org/fhir/task-status') + """ + .trimIndent(), + ) + .let { + it.moveToFirst() + retrievedTask = it.getString(0) + } + } + migratedDatabase.close() + + assertThat(retrievedTask).isEqualTo(bedNetTask) + } + companion object { const val DB_NAME = "migration_tests.db" } 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 b4280911c0..1b89d8e951 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 @@ -49,6 +49,7 @@ import org.junit.runner.RunWith class LocalChangeDaoTest { private lateinit var database: ResourceDatabase private lateinit var localChangeDao: LocalChangeDao + private val iParser = FhirContext.forR4Cached().newJsonParser() @Before fun setupDatabase() { @@ -62,7 +63,6 @@ class LocalChangeDaoTest { localChangeDao = database.localChangeDao().also { - it.iParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() it.fhirTerser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) } } @@ -97,8 +97,7 @@ class LocalChangeDaoTest { assertThat(carePlanLocalChange1.resourceUuid).isEqualTo(carePlanResourceUuid) assertThat(carePlanLocalChange1.resourceId).isEqualTo(carePlan.id) assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) - assertThat(carePlanLocalChange1.payload) - .isEqualTo(localChangeDao.iParser.encodeResourceToString(carePlan)) + assertThat(carePlanLocalChange1.payload).isEqualTo(iParser.encodeResourceToString(carePlan)) val carePlanLocalChange1Id = carePlanLocalChange1.id val localChangeResourceReferences = @@ -150,7 +149,7 @@ class LocalChangeDaoTest { resourceId = originalCarePlan.logicalId, resourceType = originalCarePlan.resourceType, resourceUuid = carePlanResourceUuid, - serializedResource = localChangeDao.iParser.encodeResourceToString(originalCarePlan), + serializedResource = iParser.encodeResourceToString(originalCarePlan), ), updatedResource = modifiedCarePlan, timeOfLocalChange = carePlanUpdateTime, @@ -163,7 +162,7 @@ class LocalChangeDaoTest { assertThat(carePlanLocalChange1.resourceId).isEqualTo(originalCarePlan.id) assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) assertThat(carePlanLocalChange1.payload) - .isEqualTo(localChangeDao.iParser.encodeResourceToString(originalCarePlan)) + .isEqualTo(iParser.encodeResourceToString(originalCarePlan)) val carePlanLocalChange2 = carePlanLocalChanges[1] assertThat(carePlanLocalChange2.resourceUuid).isEqualTo(carePlanResourceUuid) @@ -224,8 +223,7 @@ class LocalChangeDaoTest { assertThat(carePlanLocalChange1.resourceUuid).isEqualTo(carePlanResourceUuid) assertThat(carePlanLocalChange1.resourceId).isEqualTo(carePlan.id) assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) - assertThat(carePlanLocalChange1.payload) - .isEqualTo(localChangeDao.iParser.encodeResourceToString(carePlan)) + assertThat(carePlanLocalChange1.payload).isEqualTo(iParser.encodeResourceToString(carePlan)) val carePlanLocalChange2 = carePlanLocalChanges[1] assertThat(carePlanLocalChange2.resourceUuid).isEqualTo(carePlanResourceUuid) @@ -285,7 +283,7 @@ class LocalChangeDaoTest { resourceId = originalCarePlan.logicalId, resourceType = originalCarePlan.resourceType, resourceUuid = carePlanResourceUuid, - serializedResource = localChangeDao.iParser.encodeResourceToString(originalCarePlan), + serializedResource = iParser.encodeResourceToString(originalCarePlan), ), updatedResource = modifiedCarePlan, timeOfLocalChange = carePlanUpdateTime, @@ -318,7 +316,7 @@ class LocalChangeDaoTest { activityFirstRep.detail.performer.add(Reference("Patient/$updatedPatientId")) } assertThat(carePlanLocalChange1.payload) - .isEqualTo(localChangeDao.iParser.encodeResourceToString(updatedReferencesCarePlan)) + .isEqualTo(iParser.encodeResourceToString(updatedReferencesCarePlan)) val carePlanLocalChange1Id = carePlanLocalChange1.id // assert that LocalChangeReferences are updated as well val localChange1ResourceReferences = diff --git a/engine/src/main/java/com/google/android/fhir/FhirServices.kt b/engine/src/main/java/com/google/android/fhir/FhirServices.kt index efa76f0536..bf95e983be 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirServices.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirServices.kt @@ -18,8 +18,6 @@ package com.google.android.fhir import android.content.Context import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum -import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.util.FhirTerser import com.google.android.fhir.db.Database import com.google.android.fhir.db.impl.DatabaseConfig @@ -38,7 +36,6 @@ import timber.log.Timber internal data class FhirServices( val fhirEngine: FhirEngine, - val parser: IParser, val database: Database, val remoteDataSource: DataSource? = null, val fhirDataStore: FhirDataStore, @@ -74,15 +71,13 @@ internal data class FhirServices( } fun build(): FhirServices { - val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() - val terser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) + val terser = FhirTerser(FhirContext.forR4Cached()) val searchParamMap = searchParameters?.asMapOfResourceTypeToSearchParamDefinitions() ?: emptyMap() val provider = SearchParamDefinitionsProviderImpl(searchParamMap) val db = DatabaseImpl( context = context, - iParser = parser, fhirTerser = terser, DatabaseConfig(inMemory, enableEncryption, databaseErrorStrategy), resourceIndexer = ResourceIndexer(provider), @@ -100,7 +95,6 @@ internal data class FhirServices( } return FhirServices( fhirEngine = engine, - parser = parser, database = db, remoteDataSource = remoteDataSource, fhirDataStore = FhirDataStore(context), 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 40de8d320b..1e8333ea5b 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 @@ -22,7 +22,6 @@ import androidx.room.Room import androidx.room.withTransaction import androidx.sqlite.db.SimpleSQLiteQuery import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.util.FhirTerser import com.google.android.fhir.DatabaseErrorStrategy import com.google.android.fhir.LocalChange @@ -56,7 +55,6 @@ import org.hl7.fhir.r4.model.ResourceType @Suppress("UNCHECKED_CAST") internal class DatabaseImpl( private val context: Context, - private val iParser: IParser, private val fhirTerser: FhirTerser, databaseConfig: DatabaseConfig, private val resourceIndexer: ResourceIndexer, @@ -116,23 +114,15 @@ internal class DatabaseImpl( MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, + Migration_8_9, ) } .build() } - private val resourceDao by lazy { - db.resourceDao().also { - it.iParser = iParser - it.resourceIndexer = resourceIndexer - } - } + private val resourceDao by lazy { db.resourceDao().also { it.resourceIndexer = resourceIndexer } } - private val localChangeDao = - db.localChangeDao().also { - it.iParser = iParser - it.fhirTerser = fhirTerser - } + private val localChangeDao = db.localChangeDao().also { it.fhirTerser = fhirTerser } override suspend fun insert(vararg resource: R): List { val logicalIds = mutableListOf() @@ -190,10 +180,13 @@ internal class DatabaseImpl( db.withTransaction { resourceDao.getResourceEntity(oldResourceId, resourceType)?.let { oldResourceEntity -> val updatedResource = - (iParser.parseResource(oldResourceEntity.serializedResource) as Resource).apply { - idElement = IdType(newResourceId) - updateMeta(versionId, lastUpdatedRemote) - } + (FhirContext.forR4Cached() + .newJsonParser() + .parseResource(oldResourceEntity.serializedResource) as Resource) + .apply { + idElement = IdType(newResourceId) + updateMeta(versionId, lastUpdatedRemote) + } updateResourceAndReferences(oldResourceId, updatedResource) } } @@ -201,7 +194,7 @@ internal class DatabaseImpl( override suspend fun select(type: ResourceType, id: String): Resource { return resourceDao.getResource(resourceId = id, resourceType = type)?.let { - iParser.parseResource(it) as Resource + FhirContext.forR4Cached().newJsonParser().parseResource(it) as Resource } ?: throw ResourceNotFoundException(type.name, id) } @@ -229,50 +222,48 @@ internal class DatabaseImpl( override suspend fun search( query: SearchQuery, ): List> { - return db.withTransaction { - resourceDao.getResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())).pmap( - Dispatchers.Default, - ) { - ResourceWithUUID( - it.uuid, - FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) as R, - ) - } + return resourceDao.getResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())).pmap( + Dispatchers.Default, + ) { + ResourceWithUUID( + it.uuid, + FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) as R, + ) } } override suspend fun searchForwardReferencedResources( query: SearchQuery, ): List { - return db.withTransaction { - resourceDao - .getForwardReferencedResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) - .pmap(Dispatchers.Default) { - ForwardIncludeSearchResult( - it.matchingIndex, - it.baseResourceUUID, - FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) - as Resource, - ) - } - } + return resourceDao + .getForwardReferencedResources( + SimpleSQLiteQuery(query.query, query.args.toTypedArray()), + ) + .pmap(Dispatchers.Default) { + ForwardIncludeSearchResult( + it.matchingIndex, + it.baseResourceUUID, + FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) + as Resource, + ) + } } override suspend fun searchReverseReferencedResources( query: SearchQuery, ): List { - return db.withTransaction { - resourceDao - .getReverseReferencedResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) - .pmap(Dispatchers.Default) { - ReverseIncludeSearchResult( - it.matchingIndex, - it.baseResourceTypeAndId, - FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) - as Resource, - ) - } - } + return resourceDao + .getReverseReferencedResources( + SimpleSQLiteQuery(query.query, query.args.toTypedArray()), + ) + .pmap(Dispatchers.Default) { + ReverseIncludeSearchResult( + it.matchingIndex, + it.baseResourceTypeAndId, + FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) + as Resource, + ) + } } override suspend fun count(query: SearchQuery): Long { @@ -318,7 +309,10 @@ internal class DatabaseImpl( ) { db.withTransaction { val currentResourceEntity = selectEntity(updatedResource.resourceType, currentResourceId) - val oldResource = iParser.parseResource(currentResourceEntity.serializedResource) as Resource + val oldResource = + FhirContext.forR4Cached() + .newJsonParser() + .parseResource(currentResourceEntity.serializedResource) as Resource val resourceUuid = currentResourceEntity.resourceUuid updateResourceEntity(resourceUuid, updatedResource) @@ -376,6 +370,7 @@ internal class DatabaseImpl( val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" referringResourcesUuids.forEach { resourceUuid -> resourceDao.getResourceEntity(resourceUuid)?.let { + val iParser = FhirContext.forR4Cached().newJsonParser() val referringResource = iParser.parseResource(it.serializedResource) as Resource val updatedReferringResource = addUpdatedReferenceToResource( diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index 158bb4b146..8b6c6a483d 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -54,7 +54,7 @@ import org.json.JSONObject PositionIndexEntity::class, LocalChangeResourceReferenceEntity::class, ], - version = 8, + version = 9, exportSchema = true, ) @TypeConverters(DbTypeConverters::class) @@ -208,3 +208,21 @@ internal val MIGRATION_7_8 = } } } + +internal val Migration_8_9 = + object : Migration(8, 9) { + override fun migrate(database: SupportSQLiteDatabase) { + database.beginTransaction() + try { + database.execSQL( + "DROP INDEX IF EXISTS `index_TokenIndexEntity_resourceType_index_name_index_system_index_value_resourceUuid`;", + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_TokenIndexEntity_resourceType_index_name_index_value_resourceUuid` ON `TokenIndexEntity` (`resourceType`, `index_name`, `index_value`, `resourceUuid`);", + ) + database.setTransactionSuccessful() + } finally { + database.endTransaction() + } + } + } 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 68069b8ab7..158067cb85 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 @@ -21,6 +21,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.util.FhirTerser import ca.uhn.fhir.util.ResourceReferenceInfo @@ -50,8 +51,6 @@ import timber.log.Timber */ @Dao internal abstract class LocalChangeDao { - - lateinit var iParser: IParser lateinit var fhirTerser: FhirTerser @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -70,7 +69,7 @@ internal abstract class LocalChangeDao { open suspend fun addInsert(resource: Resource, resourceUuid: UUID, timeOfLocalChange: Instant) { val resourceId = resource.logicalId val resourceType = resource.resourceType - val resourceString = iParser.encodeResourceToString(resource) + val resourceString = FhirContext.forR4Cached().newJsonParser().encodeResourceToString(resource) val localChangeEntity = LocalChangeEntity( @@ -128,6 +127,7 @@ internal abstract class LocalChangeDao { "Unexpected DELETE when updating $resourceType/$resourceId. UPDATE failed.", ) } + val iParser = FhirContext.forR4Cached().newJsonParser() val oldResource = iParser.parseResource(oldEntity.serializedResource) as Resource val jsonDiff = diff(iParser, oldResource, updatedResource) if (jsonDiff.length() == 0) { @@ -475,6 +475,7 @@ internal abstract class LocalChangeDao { oldReference: String, updatedReference: String, ): LocalChangeEntity { + val iParser = FhirContext.forR4Cached().newJsonParser() return when (localChange.type) { LocalChangeEntity.Type.INSERT -> { val insertResourcePayload = iParser.parseResource(localChange.payload) as Resource 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 0f219d84dd..65015fc9c8 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 @@ -24,7 +24,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery -import ca.uhn.fhir.parser.IParser +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.db.impl.entities.DateIndexEntity import com.google.android.fhir.db.impl.entities.DateTimeIndexEntity @@ -55,7 +55,6 @@ import org.hl7.fhir.r4.model.ResourceType internal abstract class ResourceDao { // this is ugly but there is no way to inject these right now in Room as it is the one creating // the dao - lateinit var iParser: IParser lateinit var resourceIndexer: ResourceIndexer /** @@ -69,7 +68,8 @@ internal abstract class ResourceDao { getResourceEntity(resource.logicalId, resource.resourceType)?.let { val entity = it.copy( - serializedResource = iParser.encodeResourceToString(resource), + serializedResource = + FhirContext.forR4Cached().newJsonParser().encodeResourceToString(resource), lastUpdatedLocal = timeOfLocalChange, lastUpdatedRemote = resource.meta.lastUpdated?.toInstant() ?: it.lastUpdatedRemote, ) @@ -86,7 +86,8 @@ internal abstract class ResourceDao { val entity = it.copy( resourceId = updatedResource.logicalId, - serializedResource = iParser.encodeResourceToString(updatedResource), + serializedResource = + FhirContext.forR4Cached().newJsonParser().encodeResourceToString(updatedResource), lastUpdatedRemote = updatedResource.lastUpdated ?: it.lastUpdatedRemote, versionId = updatedResource.versionId ?: it.versionId, ) @@ -107,7 +108,8 @@ internal abstract class ResourceDao { getResourceEntity(resource.logicalId, resource.resourceType)?.let { val entity = it.copy( - serializedResource = iParser.encodeResourceToString(resource), + serializedResource = + FhirContext.forR4Cached().newJsonParser().encodeResourceToString(resource), lastUpdatedRemote = resource.meta.lastUpdated?.toInstant(), versionId = resource.versionId, ) @@ -267,7 +269,8 @@ internal abstract class ResourceDao { resourceType = resource.resourceType, resourceUuid = resourceUuid, resourceId = resource.logicalId, - serializedResource = iParser.encodeResourceToString(resource), + serializedResource = + FhirContext.forR4Cached().newJsonParser().encodeResourceToString(resource), versionId = resource.versionId, lastUpdatedRemote = resource.lastUpdated, lastUpdatedLocal = lastUpdatedLocal, @@ -297,7 +300,10 @@ internal abstract class ResourceDao { lastUpdatedRemote: Instant?, ) { getResourceEntity(resourceId, resourceType)?.let { oldResourceEntity -> - val resource = iParser.parseResource(oldResourceEntity.serializedResource) as Resource + val resource = + FhirContext.forR4Cached() + .newJsonParser() + .parseResource(oldResourceEntity.serializedResource) as Resource resource.updateMeta(versionId, lastUpdatedRemote) updateResourceWithUuid(oldResourceEntity.resourceUuid, resource) } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/TokenIndexEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/TokenIndexEntity.kt index c08d360f5d..2c495ab146 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/entities/TokenIndexEntity.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/TokenIndexEntity.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. @@ -28,7 +28,7 @@ import org.hl7.fhir.r4.model.ResourceType @Entity( indices = [ - Index(value = ["resourceType", "index_name", "index_system", "index_value", "resourceUuid"]), + Index(value = ["resourceType", "index_name", "index_value", "resourceUuid"]), // Keep this index for faster foreign lookup Index(value = ["resourceUuid"]), ], diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/UrlRequestGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UrlRequestGenerator.kt index 8e576760c3..d868cf2467 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/UrlRequestGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UrlRequestGenerator.kt @@ -17,7 +17,6 @@ package com.google.android.fhir.sync.upload.request import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.ContentTypes import com.google.android.fhir.sync.upload.patch.Patch import com.google.android.fhir.sync.upload.patch.PatchMapping @@ -58,8 +57,6 @@ internal class UrlRequestGenerator( companion object Factory { - private val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() - private val createMapping = mapOf( HttpVerb.POST to this::postForCreateResource, @@ -107,21 +104,24 @@ internal class UrlRequestGenerator( UrlUploadRequest( httpVerb = HttpVerb.DELETE, url = "${patch.resourceType}/${patch.resourceId}", - resource = parser.parseResource(patch.payload) as Resource, + resource = + FhirContext.forR4Cached().newJsonParser().parseResource(patch.payload) as Resource, ) private fun postForCreateResource(patch: Patch) = UrlUploadRequest( httpVerb = HttpVerb.POST, url = patch.resourceType, - resource = parser.parseResource(patch.payload) as Resource, + resource = + FhirContext.forR4Cached().newJsonParser().parseResource(patch.payload) as Resource, ) private fun putForCreateResource(patch: Patch) = UrlUploadRequest( httpVerb = HttpVerb.PUT, url = "${patch.resourceType}/${patch.resourceId}", - resource = parser.parseResource(patch.payload) as Resource, + resource = + FhirContext.forR4Cached().newJsonParser().parseResource(patch.payload) as Resource, ) private fun patchForUpdateResource(patch: Patch) = diff --git a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt index 31303f3e88..64bfc6ace4 100644 --- a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt @@ -17,6 +17,7 @@ package com.google.android.fhir.impl import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.rest.gclient.TokenClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.FhirServices.Companion.builder @@ -74,6 +75,7 @@ import org.robolectric.RobolectricTestRunner class FhirEngineImplTest { private val services = builder(ApplicationProvider.getApplicationContext()).inMemory().build() private val fhirEngine = services.fhirEngine + private val parser = FhirContext.forR4Cached().newJsonParser() @Before fun setUp(): Unit = runBlocking { fhirEngine.create(TEST_PATIENT_1) } @@ -388,7 +390,7 @@ class FhirEngineImplTest { assertThat(resourceType).isEqualTo(ResourceType.Patient.toString()) assertThat(resourceId).isEqualTo(TEST_PATIENT_1.id) assertThat(type).isEqualTo(Type.INSERT) - assertThat(payload).isEqualTo(services.parser.encodeResourceToString(TEST_PATIENT_1)) + assertThat(payload).isEqualTo(parser.encodeResourceToString(TEST_PATIENT_1)) } assertThat(emittedProgress).hasSize(2) @@ -446,7 +448,7 @@ class FhirEngineImplTest { fun `getLocalChanges() should return single local change`() = runBlocking { val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") fhirEngine.create(patient) - val patientString = services.parser.encodeResourceToString(patient) + val patientString = parser.encodeResourceToString(patient) val resourceLocalChanges = fhirEngine.getLocalChanges(patient.resourceType, patient.logicalId) with(resourceLocalChanges) { assertThat(size).isEqualTo(1) @@ -497,7 +499,7 @@ class FhirEngineImplTest { fun `clearDatabase() should clear all tables data`() = runBlocking { val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") fhirEngine.create(patient) - val patientString = services.parser.encodeResourceToString(patient) + val patientString = parser.encodeResourceToString(patient) val resourceLocalChanges = fhirEngine.getLocalChanges(patient.resourceType, patient.logicalId) with(resourceLocalChanges) { assertThat(size).isEqualTo(1) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5edb4df63..79fc02d4d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ android-fhir-common = "0.1.0-alpha05" android-fhir-engine = "0.1.0-beta05" android-fhir-knowledge = "0.1.0-beta01" -androidx-acivity = "1.7.2" +androidx-activity = "1.7.2" androidx-appcompat = "1.6.1" androidx-arch-core = "2.2.0" androidx-benchmark = "1.1.1" @@ -31,12 +31,13 @@ kotlinx-coroutines = "1.8.1" logback-android = "3.0.0" opencds-cqf-fhir = "3.8.0" truth = "1.1.5" +material = "1.9.0" [libraries] android-fhir-common = { module = "com.google.android.fhir:common", version.ref = "android-fhir-common" } android-fhir-engine = { module = "com.google.android.fhir:engine", version.ref = "android-fhir-engine" } android-fhir-knowledge = { module = "com.google.android.fhir:knowledge", version.ref = "android-fhir-knowledge" } -androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-acivity" } +androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "androidx-arch-core" } androidx-benchmark-junit4 = { module = "androidx.benchmark:benchmark-junit4", version.ref = "androidx-benchmark" } @@ -78,6 +79,7 @@ opencds-cqf-fhir-cr = { module = "org.opencds.cqf.fhir:cqf-fhir-cr", version.ref opencds-cqf-fhir-jackson = { module = "org.opencds.cqf.fhir:cqf-fhir-jackson", version.ref = "opencds-cqf-fhir" } opencds-cqf-fhir-utility = { module = "org.opencds.cqf.fhir:cqf-fhir-utility", version.ref = "opencds-cqf-fhir" } truth = { module = "com.google.truth:truth", version.ref = "truth" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [bundles] 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 8066cda7d9..953b936e26 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 @@ -19,7 +19,6 @@ package com.google.android.fhir.knowledge import android.content.Context import androidx.room.Room import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.parser.IParser import com.google.android.fhir.knowledge.db.KnowledgeDatabase import com.google.android.fhir.knowledge.db.entities.ImplementationGuideEntity import com.google.android.fhir.knowledge.db.entities.ResourceMetadataEntity @@ -63,7 +62,6 @@ internal constructor( knowledgeDatabase: KnowledgeDatabase, private val npmFileManager: NpmFileManager, private val npmPackageDownloader: NpmPackageDownloader, - private val jsonParser: IParser = FhirContext.forR4().newJsonParser(), ) { private val knowledgeDao = knowledgeDatabase.knowledgeDao() @@ -296,7 +294,7 @@ internal constructor( private suspend fun readResourceOrNull(file: File): IBaseResource? = withContext(Dispatchers.IO) { try { - FileInputStream(file).use(jsonParser::parseResource) + FileInputStream(file).use(FhirContext.forR4Cached().newJsonParser()::parseResource) } catch (e: Exception) { Timber.e(e, "Unable to load resource from $file") null diff --git a/mkdocs.yaml b/mkdocs.yaml index c7b8afc9e7..cad80a0b2b 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -39,6 +39,8 @@ nav: - use/WFL/Evaluate-a-Measure.md - use/WFL/Evaluate-a-Library.md - use/WFL/Compile-and-Execute-CQL.md + - use/WFL/Run-an-Activity-Flow.md + - Demo App: use/WFL/Demo-app.md - use/extensions.md - API Doc: use/api.md - Use Snapshots: use/snapshots.md diff --git a/settings.gradle.kts b/settings.gradle.kts index bc56f5a9cc..e4a2bb9676 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,3 +66,5 @@ include(":workflow-testing") include(":workflow:benchmark") include(":engine:benchmark") + +include(":workflow_demo") diff --git a/workflow_demo/.gitignore b/workflow_demo/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/workflow_demo/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/workflow_demo/README.md b/workflow_demo/README.md new file mode 100644 index 0000000000..31a7f70671 --- /dev/null +++ b/workflow_demo/README.md @@ -0,0 +1,8 @@ +# Workflow Activity API usage + +## Setup + +### Where does configuration come from + +The configurations for Activity flows available in the demo app are based on [these examples](https://build.fhir.org/ig/cqframework/cpg-example/branches/master/examples.html#examples). +The required PlanDefinition, ActivityDefinition and CQL Library are available [here](https://github.com/cqframework/cpg-example/tree/master). \ No newline at end of file diff --git a/workflow_demo/build.gradle.kts b/workflow_demo/build.gradle.kts new file mode 100644 index 0000000000..e43821dc1a --- /dev/null +++ b/workflow_demo/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.google.android.fhir.workflow.demo" + compileSdk = Sdk.COMPILE_SDK + + defaultConfig { + applicationId = Releases.WorkflowDemo.applicationId + minSdk = Sdk.MIN_SDK + targetSdk = Sdk.TARGET_SDK + versionCode = Releases.WorkflowDemo.versionCode + versionName = Releases.WorkflowDemo.versionName + testInstrumentationRunner = Dependencies.androidJunitRunner + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + // sourceCompatibility = JavaVersion.VERSION_1_8 + // targetCompatibility = JavaVersion.VERSION_1_8 + // Flag to enable support for the new language APIs + // See https://developer.android.com/studio/write/java8-support + isCoreLibraryDesugaringEnabled = true + } + // kotlinOptions { + // jvmTarget = "1.8" + // } + kotlin { jvmToolchain(11) } + packaging { + resources.excludes.addAll( + listOf( + "META-INF/ASL-2.0.txt", + "META-INF/LGPL-3.0.txt", + "META-INF/LICENSE.md", + "META-INF/NOTICE.md", + "META-INF/INDEX.LIST", + ), + ) + } +} + +dependencies { + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.ext.junit) + + compileOnly(libs.opencds.cqf.fhir.cr) + coreLibraryDesugaring(Dependencies.desugarJdkLibs) + + implementation(libs.androidx.appcompat) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.core) + implementation(libs.androidx.fragment) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.material) + implementation(project(":engine")) + implementation(project(":knowledge")) { + exclude(group = "com.google.android.fhir", module = "engine") + } + implementation(project(":workflow")) { + exclude(group = "com.google.android.fhir", module = "engine") + exclude(group = "com.google.android.fhir", module = "knowledge") + } + implementation(project(":workflow-testing")) + + testImplementation(libs.junit) + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + api(libName, constraints) + implementation(libName, constraints) + } + } +} diff --git a/workflow_demo/proguard-rules.pro b/workflow_demo/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/workflow_demo/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/workflow_demo/src/main/AndroidManifest.xml b/workflow_demo/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0a0ad32f27 --- /dev/null +++ b/workflow_demo/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/workflow_demo/src/main/assets/ad/DailyAppleActivity.json b/workflow_demo/src/main/assets/ad/DailyAppleActivity.json new file mode 100644 index 0000000000..cc1e05a373 --- /dev/null +++ b/workflow_demo/src/main/assets/ad/DailyAppleActivity.json @@ -0,0 +1,46 @@ +{ + "resourceType": "ActivityDefinition", + "id": "DailyAppleActivity", + "url": "http://fhir.org/guides/cqf/cpg/example/ActivityDefinition/DailyAppleActivity", + "name": "DailyAppleActivity", + "title": "Daily Apple Activity", + "status": "active", + "experimental": true, + "description": "Activity to provide an apple daily", + "kind": "MedicationRequest", + "profile": "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-medicationrequest", + "intent": "proposal", + "priority": "routine", + "productCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "735215001", + "display": "Apple (substance)" + } + ], + "text": "Apple" + }, + "dosage": [ + { + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "doseAndRate": [ + { + "doseQuantity": { + "value": 1, + "code": "{apple}" + }, + "rateQuantity": { + "value": 1 + } + } + ] + } + ] +} \ No newline at end of file diff --git a/workflow_demo/src/main/assets/cql/DailyAppleLogic.cql b/workflow_demo/src/main/assets/cql/DailyAppleLogic.cql new file mode 100644 index 0000000000..1506845b90 --- /dev/null +++ b/workflow_demo/src/main/assets/cql/DailyAppleLogic.cql @@ -0,0 +1,28 @@ +library DailyAppleLogic version '1.0.0' + +using FHIR version '4.0.1' + +include FHIRHelpers version '4.0.1' called FHIRHelpers + +valueset "Apple": 'http://fhir.org/guides/cqf/cpg/example/ValueSet/Apple' + +context Patient + +define "Inclusion Criteria": + Patient.active + +define "Active Daily Apple Order": + [MedicationRequest: "Apple"] M + where M.intent = 'order' + and M.status = 'active' + and M.doNotPerform is not true + +define "Contraindications For Daily Apple Order": + [MedicationRequest: "Apple"] M + where M.intent = 'order' + and M.status = 'active' + and M.doNotPerform is true + +define "No Daily Apple Order": + not exists ("Active Daily Apple Order") + and not exists ("Contraindications For Daily Apple Order") diff --git a/workflow_demo/src/main/assets/pd/DailyAppleRecommendation.json b/workflow_demo/src/main/assets/pd/DailyAppleRecommendation.json new file mode 100644 index 0000000000..7c36662c98 --- /dev/null +++ b/workflow_demo/src/main/assets/pd/DailyAppleRecommendation.json @@ -0,0 +1,26 @@ +{ + "resourceType": "PlanDefinition", + "id": "DailyAppleRecommendation", + "url": "http://fhir.org/guides/cqf/cpg/example/PlanDefinition/DailyAppleRecommendation", + "name": "DailyAppleRecommendation", + "title": "Daily Apple Recommendation", + "description": "An apple a day keeps the doctor away", + "library": [ + "http://fhir.org/guides/cqf/cpg/example/Library/DailyAppleLogic" + ], + "action": [ + { + "textEquivalent": "An apple a day keeps the doctor away", + "condition": [ + { + "kind": "applicability", + "expression": { + "language": "text/cql-identifier", + "expression": "No Daily Apple Order" + } + } + ], + "definitionCanonical": "http://fhir.org/guides/cqf/cpg/example/ActivityDefinition/DailyAppleActivity" + } + ] +} \ No newline at end of file diff --git a/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/MainActivity.kt b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/MainActivity.kt new file mode 100644 index 0000000000..6dfc535c48 --- /dev/null +++ b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/MainActivity.kt @@ -0,0 +1,35 @@ +/* + * 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.workflow.demo + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.google.android.fhir.workflow.demo.ui.main.MainFragment + +class MainActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + if (savedInstanceState == null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.container, MainFragment.newInstance()) + .commitNow() + } + } +} diff --git a/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/helper/ResourceLoader.kt b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/helper/ResourceLoader.kt new file mode 100644 index 0000000000..583c77b975 --- /dev/null +++ b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/helper/ResourceLoader.kt @@ -0,0 +1,75 @@ +/* + * 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.workflow.demo.helper + +import android.content.Context +import ca.uhn.fhir.context.FhirContext +import com.google.android.fhir.workflow.testing.CqlBuilder +import java.io.InputStream +import kotlin.reflect.KSuspendFunction1 +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Library +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType + +class ResourceLoader( + private val fhirContext: FhirContext = FhirContext.forR4(), + private val androidContext: Context, +) { + private val jsonParser = fhirContext.newJsonParser() + + suspend fun loadFile( + path: String, + importFunction: KSuspendFunction1, + ) { + val resource = + if (path.endsWith(".json")) { + jsonParser.parseResource(open(path)) as Resource + } else if (path.endsWith(".cql")) { + toFhirLibrary(open(path)) + } else { + throw IllegalArgumentException("Only json and cql files are supported") + } + loadResource(resource, importFunction) + } + + private suspend fun loadResource( + resource: Resource, + importFunction: KSuspendFunction1, + ) { + when (resource.resourceType) { + ResourceType.Bundle -> loadBundle(resource as Bundle, importFunction) + else -> importFunction(resource) + } + } + + private suspend fun loadBundle( + bundle: Bundle, + importFunction: KSuspendFunction1, + ) { + for (entry in bundle.entry) { + val resource = entry.resource + loadResource(resource, importFunction) + } + } + + private fun toFhirLibrary(cql: InputStream): Library { + return CqlBuilder.compileAndBuild(cql) + } + + private fun open(path: String) = androidContext.assets.open(path) +} diff --git a/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/ActivityFlowAdapter.kt b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/ActivityFlowAdapter.kt new file mode 100644 index 0000000000..8d2e50c2d1 --- /dev/null +++ b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/ActivityFlowAdapter.kt @@ -0,0 +1,73 @@ +/* + * 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.workflow.demo.ui.main + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.workflow.demo.R +import com.google.android.material.button.MaterialButton +import com.google.android.material.textview.MaterialTextView + +class ActivityFlowAdapter : + ListAdapter(DataModelDiffUtil) { + + class ViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + + private val name: MaterialTextView = view.findViewById(R.id.phase_name) + private val details: MaterialTextView = view.findViewById(R.id.phase_details) + private val selection: MaterialButton = view.findViewById(R.id.start_phase) + + @SuppressLint("SetTextI18n") + fun bind(model: DataModel) { + name.text = "Phase: ${model.phase.name}" + details.text = model.details + selection.isEnabled = model.isActive + selection.setOnClickListener { model.onclick(model.phase) } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.card_item_phase, parent, false), + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +data class DataModel( + val phase: FlowPhase, + val details: String, + val isActive: Boolean, + val onclick: (phase: FlowPhase) -> Unit, +) + +object DataModelDiffUtil : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DataModel, newItem: DataModel): Boolean { + return oldItem.phase == newItem.phase + } + + override fun areContentsTheSame(oldItem: DataModel, newItem: DataModel): Boolean { + return oldItem == newItem + } +} diff --git a/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/ActivityHandler.kt b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/ActivityHandler.kt new file mode 100644 index 0000000000..bdc668ad59 --- /dev/null +++ b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/ActivityHandler.kt @@ -0,0 +1,90 @@ +/* + * 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.workflow.demo.ui.main + +import com.google.android.fhir.workflow.activity.ActivityFlow +import com.google.android.fhir.workflow.activity.phase.Phase +import com.google.android.fhir.workflow.activity.resource.event.CPGEventResource +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import com.google.android.fhir.workflow.activity.resource.request.Status + +class ActivityHandler( + private val activityFlow: ActivityFlow, CPGEventResource<*>>, +) { + + suspend fun prepareAndInitiatePlan(): Result { + // Get the current phase. + val currentPhase = activityFlow.getCurrentPhase() as Phase.RequestPhase> + // ActivityFlow returns a copy of the resource it has, so after updating the returned + // resource, give it back to the ActivityFlow by calling update api on the phase. + currentPhase.update( + currentPhase.getRequestResource().apply { setStatus(Status.ACTIVE) }, + ) + + val preparedPlan = activityFlow.preparePlan() + if (preparedPlan.isFailure) return Result.failure(preparedPlan.exceptionOrNull()!!) + + val initiatedPlan = activityFlow.initiatePlan(preparedPlan.getOrThrow()) + + return if (initiatedPlan.isFailure) { + Result.failure(initiatedPlan.exceptionOrNull()!!) + } else { + Result.success(true) + } + } + + suspend fun prepareAndInitiateOrder(): Result { + // Get the current phase. + val currentPhase = activityFlow.getCurrentPhase() as Phase.RequestPhase> + // ActivityFlow returns a copy of the resource it has, so after updating the returned + // resource, give it back to the ActivityFlow by calling update api on the phase. + currentPhase.update( + currentPhase.getRequestResource().apply { setStatus(Status.ACTIVE) }, + ) + + val preparedOrder = activityFlow.prepareOrder() + if (preparedOrder.isFailure) return Result.failure(preparedOrder.exceptionOrNull()!!) + + val initiatedOrder = activityFlow.initiateOrder(preparedOrder.getOrThrow()) + + return if (initiatedOrder.isFailure) { + Result.failure(initiatedOrder.exceptionOrNull()!!) + } else { + Result.success(true) + } + } + + suspend fun prepareAndInitiatePerform(clazz: Class>): Result { + val currentPhase = activityFlow.getCurrentPhase() as Phase.RequestPhase> + // ActivityFlow returns a copy of the resource it has, so after updating the returned + // resource, give it back to the ActivityFlow by calling update api on the phase. + currentPhase.update( + currentPhase.getRequestResource().apply { setStatus(Status.ACTIVE) }, + ) + + val preparedEvent = activityFlow.preparePerform(clazz) + if (preparedEvent.isFailure) return Result.failure(preparedEvent.exceptionOrNull()!!) + + val initiatedEvent = activityFlow.initiatePerform(preparedEvent.getOrThrow()) + + return if (initiatedEvent.isFailure) { + Result.failure(initiatedEvent.exceptionOrNull()!!) + } else { + Result.success(true) + } + } +} diff --git a/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/MainFragment.kt b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/MainFragment.kt new file mode 100644 index 0000000000..e6ffa32613 --- /dev/null +++ b/workflow_demo/src/main/java/com/google/android/fhir/workflow/demo/ui/main/MainFragment.kt @@ -0,0 +1,96 @@ +/* + * 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.workflow.demo.ui.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ProgressBar +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.fhir.workflow.demo.R +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import kotlinx.coroutines.launch + +class MainFragment : Fragment() { + + companion object { + fun newInstance() = MainFragment() + } + + private val viewModel: MainViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return inflater.inflate(R.layout.fragment_main, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val pager: ViewPager2 = view.findViewById(R.id.phase_view_pager) + val tabLayout = view.findViewById(R.id.into_tab_layout) + val progressView = view.findViewById(R.id.progress_circular) + + val adapter = ActivityFlowAdapter() + pager.adapter = adapter + viewLifecycleOwner.lifecycleScope.launch { + viewModel.adapterData.collect { + adapter.submitList(it) + + tabLayout.postDelayed( + { tabLayout.getTabAt(it.indexOfFirst { it.isActive })?.select() }, + 1000, + ) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.progressFlow.collect { + progressView.visibility = if (it) View.VISIBLE else View.GONE + } + } + + TabLayoutMediator(tabLayout, pager) { tab, position -> }.attach() + + view.findViewById