From 6a6724a0c8999b824d4c7c8e9a7f7489c6ae40eb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Oct 2023 03:25:59 -0400 Subject: [PATCH 01/69] Renames patientId param to subject to: (#2263) 1. clarify the need to send a full reference "type/id" instead of just the ID 2. match the evaluator's name --- .../java/com/google/android/fhir/workflow/FhirOperator.kt | 8 ++++---- .../com/google/android/fhir/workflow/FhirOperatorTest.kt | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt index e5d0c820ea..3fa8e21923 100644 --- a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt +++ b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt @@ -185,8 +185,8 @@ internal constructor( * from a worker thread or it may throw [BlockingMainThreadException] exception. */ @WorkerThread - fun generateCarePlan(planDefinitionId: String, patientId: String): IBaseResource { - return generateCarePlan(planDefinitionId, patientId, encounterId = null) + fun generateCarePlan(planDefinitionId: String, subject: String): IBaseResource { + return generateCarePlan(planDefinitionId, subject, encounterId = null) } /** @@ -198,14 +198,14 @@ internal constructor( @WorkerThread fun generateCarePlan( planDefinitionId: String, - patientId: String, + subject: String, encounterId: String?, ): IBaseResource { return planDefinitionProcessor.apply( /* id = */ IdType("PlanDefinition", planDefinitionId), /* canonical = */ null, /* planDefinition = */ null, - /* subject = */ patientId, + /* subject = */ subject, /* encounterId = */ encounterId, /* practitionerId = */ null, /* organizationId = */ null, diff --git a/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt b/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt index 712f4dcdff..95f4ced23a 100644 --- a/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt +++ b/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt @@ -99,7 +99,7 @@ class FhirOperatorTest { assertThat( fhirOperator.generateCarePlan( planDefinitionId = "plandefinition-RuleFilters-1.0.0", - patientId = "Reportable", + subject = "Patient/Reportable", encounterId = "reportable-encounter", ), ) @@ -114,7 +114,7 @@ class FhirOperatorTest { val carePlan = fhirOperator.generateCarePlan( planDefinitionId = "MedRequest-Example", - patientId = "Patient/Patient-Example", + subject = "Patient/Patient-Example", ) println(jsonParser.encodeResourceToString(carePlan)) @@ -138,7 +138,7 @@ class FhirOperatorTest { val carePlan = fhirOperator.generateCarePlan( planDefinitionId = "Plan-Definition-Example", - patientId = "Patient/Female-Patient-Example", + subject = "Patient/Female-Patient-Example", ) println(jsonParser.setPrettyPrint(true).encodeResourceToString(carePlan)) From 4e56a24a9655fefcdeda624f716f833ff5452d11 Mon Sep 17 00:00:00 2001 From: Fikri Milano Date: Mon, 16 Oct 2023 16:11:26 +0700 Subject: [PATCH 02/69] Follow up PR of 2025 (#2265) * Use launchContext for initialExpression * Test ResourceMapper * Fix test * Fix launchContexts for demo app when editing patient * spotlessApply * WIP * Revert "WIP" This reverts commit d2d53a2d5c7a92a503468a5b26f66b9691aa348e. * spotlessApply * Fix test * Refactor validateLaunchContextExtension * Remove QuestionnaireLaunchContextSet enum class * Rename vars and functions * Add code comment for MoreResourceTypes.kt * Unit testing * Fix post-merge-conflict * spotlessApply * Address review * spotlessApply * Revert un-intended changes * Fix failing checks * Remove check of must contain 2 sub-extensions * Update Kdoc * Revert * validate launch context when using populate public API * Address Jing's Review - https://github.com/google/android-fhir/pull/2025#discussion_r1358711754 - https://github.com/google/android-fhir/pull/2025#discussion_r1358714996 --- .../fhir/datacapture/mapping/ResourceMapper.kt | 10 +++++++++- .../fhir/datacapture/mapping/ResourceMapperTest.kt | 14 +------------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt index eeb37f4ca3..3f49256162 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt @@ -252,7 +252,15 @@ object ResourceMapper { questionnaireItem.initialExpression ?.let { - fhirPathEngine.evaluate(launchContexts, null, null, null, it.expression).firstOrNull() + fhirPathEngine + .evaluate( + /* appContext= */ launchContexts, + /* focusResource= */ null, + /* rootResource= */ null, + /* base= */ null, + /* path= */ it.expression, + ) + .firstOrNull() } ?.let { // Set initial value for the questionnaire item. Questionnaire items should not have both diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt index 4c88f02408..cb9e0f2b2d 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt @@ -812,16 +812,6 @@ class ResourceMapperTest { runBlocking { val questionnaire = Questionnaire() - .apply { - addExtension().apply { - url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT - extension = - listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "mother", "Mother")), - Extension("type", CodeType("Patient")), - ) - } - } .addItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = "patient-dob" @@ -839,9 +829,7 @@ class ResourceMapperTest { }, ) - val patientId = UUID.randomUUID().toString() - val patient = Patient().apply { id = "Patient/$patientId/_history/2" } - val questionnaireResponse = ResourceMapper.populate(questionnaire, mapOf("mother" to patient)) + val questionnaireResponse = ResourceMapper.populate(questionnaire, emptyMap()) assertThat((questionnaireResponse.item[0].answer[0].value as DateType).localDate) .isEqualTo((DateType(Date())).localDate) From c791082c1e0dcb8a58a9e972f46782723e25bc20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:36:39 +0100 Subject: [PATCH 03/69] Bump com.squareup:kotlinpoet from 1.12.0 to 1.14.2 (#2255) Bumps [com.squareup:kotlinpoet](https://github.com/square/kotlinpoet) from 1.12.0 to 1.14.2. - [Release notes](https://github.com/square/kotlinpoet/releases) - [Changelog](https://github.com/square/kotlinpoet/blob/main/docs/changelog.md) - [Commits](https://github.com/square/kotlinpoet/compare/1.12.0...1.14.2) --- updated-dependencies: - dependency-name: com.squareup:kotlinpoet dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jing Tang --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index b515c1f35d..6899465294 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -19,5 +19,5 @@ dependencies { implementation("com.spotify.ruler:ruler-gradle-plugin:1.2.1") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.8.0") - implementation("com.squareup:kotlinpoet:1.12.0") + implementation("com.squareup:kotlinpoet:1.14.2") } From dae93c30163eef2e98ebeb958882ab4b15be2869 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 14:56:45 +0100 Subject: [PATCH 04/69] Bump androidx.build.gradle.gcpbuildcache (#2266) Bumps androidx.build.gradle.gcpbuildcache from 1.0.0-beta01 to 1.0.0-beta05. --- updated-dependencies: - dependency-name: androidx.build.gradle.gcpbuildcache dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index a3fb255a35..6993d48be4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,7 +3,7 @@ import androidx.build.gradle.gcpbuildcache.GcpBuildCacheServiceFactory plugins { id("com.gradle.enterprise") version ("3.10") - id("androidx.build.gradle.gcpbuildcache") version "1.0.0-beta01" + id("androidx.build.gradle.gcpbuildcache") version "1.0.0-beta05" id("org.gradle.toolchains.foojay-resolver-convention") version ("0.5.0") } From 047591c3b8258729e25453be92a7d3d5ed27af0a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 17:09:29 +0100 Subject: [PATCH 05/69] Bump com.spotify.ruler:ruler-gradle-plugin from 1.2.1 to 1.4.0 (#2253) Bumps [com.spotify.ruler:ruler-gradle-plugin](https://github.com/spotify/ruler) from 1.2.1 to 1.4.0. - [Release notes](https://github.com/spotify/ruler/releases) - [Changelog](https://github.com/spotify/ruler/blob/main/changelog.md) - [Commits](https://github.com/spotify/ruler/compare/v1.2.1...v1.4.0) --- updated-dependencies: - dependency-name: com.spotify.ruler:ruler-gradle-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 6899465294..5425600c60 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -16,7 +16,7 @@ dependencies { implementation("app.cash.licensee:licensee-gradle-plugin:1.3.0") implementation("com.osacky.flank.gradle:fladle:0.17.4") - implementation("com.spotify.ruler:ruler-gradle-plugin:1.2.1") + implementation("com.spotify.ruler:ruler-gradle-plugin:1.4.0") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.8.0") implementation("com.squareup:kotlinpoet:1.14.2") From 897cbc3e8acf5eae6d939b4ecc53a128bc9d0326 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 18:06:11 +0100 Subject: [PATCH 06/69] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.21.0 to 6.22.0 (#2252) Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.21.0 to 6.22.0. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/gradle/6.21.0...gradle/6.22.0) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 5425600c60..d613124145 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,7 +9,7 @@ repositories { } dependencies { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.21.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.22.0") implementation("com.android.tools.build:gradle:8.1.2") From 46f73341964caf49da04405ba86a8b613be46b0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 09:45:46 +0100 Subject: [PATCH 07/69] Bump actions/checkout from 3 to 4 (#2248) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jing Tang --- .github/workflows/build.yml | 2 +- .github/workflows/codeql.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d7a9b64d9..e30b28800f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # Fetch origin/master for spotless ratchet to work # https://github.com/diffplug/spotless/issues/1242 diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 6b30b500f4..ffbc472edd 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Switch to Java 17 from Eclipse Temurin distro uses: actions/setup-java@v3 From 9c645df7a49c72d0c8b1ce031671816c761e9251 Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Tue, 17 Oct 2023 11:25:00 +0100 Subject: [PATCH 08/69] Update dependencies in engine colab (#2268) * Update dependencies in engine colab * update datacapture --- codelabs/datacapture/app/build.gradle.kts | 37 ++++++++------ codelabs/datacapture/build.gradle.kts | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 5 +- codelabs/engine/app/build.gradle.kts | 51 ++++++++++--------- .../engine/PatientItemRecyclerViewAdapter.kt | 14 +++-- .../codelabs/engine/PatientItemViewHolder.kt | 11 ++-- .../codelabs/engine/PatientListViewModel.kt | 9 ++-- codelabs/engine/build.gradle.kts | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 5 +- 9 files changed, 78 insertions(+), 62 deletions(-) diff --git a/codelabs/datacapture/app/build.gradle.kts b/codelabs/datacapture/app/build.gradle.kts index b593a68695..0c20e3acdc 100644 --- a/codelabs/datacapture/app/build.gradle.kts +++ b/codelabs/datacapture/app/build.gradle.kts @@ -4,12 +4,13 @@ plugins { } android { - compileSdk = 31 + namespace = "com.google.codelab.sdclibrary" + compileSdk = 34 defaultConfig { applicationId = "com.google.android.fhir.codelabs.datacapture" minSdk = 24 - targetSdk = 31 + targetSdk = 34 versionCode = 1 versionName = "1.0" @@ -22,24 +23,30 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + buildFeatures { + buildConfig = true + viewBinding = true } - kotlinOptions { - jvmTarget = "1.8" + compileOptions { + // Flag to enable support for the new language APIs + // See https://developer.android.com/studio/write/java8-support + isCoreLibraryDesugaringEnabled = true } + + packaging { resources.excludes.addAll(listOf("META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt")) } + kotlin { jvmToolchain(11) } } dependencies { - implementation("androidx.core:core-ktx:1.7.0") - implementation("androidx.appcompat:appcompat:1.4.0") - implementation("com.google.android.material:material:1.4.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.2") - - testImplementation("junit:junit:4.+") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") // 3 Add dependencies for Structured Data Capture Library and Fragment KTX } diff --git a/codelabs/datacapture/build.gradle.kts b/codelabs/datacapture/build.gradle.kts index 77acf04960..dcd615f0cb 100644 --- a/codelabs/datacapture/build.gradle.kts +++ b/codelabs/datacapture/build.gradle.kts @@ -6,8 +6,8 @@ buildscript { gradlePluginPortal() } dependencies { - classpath("com.android.tools.build:gradle:7.0.4") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10") + classpath("com.android.tools.build:gradle:8.1.2") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle.kts files diff --git a/codelabs/datacapture/gradle/wrapper/gradle-wrapper.properties b/codelabs/datacapture/gradle/wrapper/gradle-wrapper.properties index b2a400b0e0..da1db5f04e 100644 --- a/codelabs/datacapture/gradle/wrapper/gradle-wrapper.properties +++ b/codelabs/datacapture/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jan 28 09:30:50 GMT 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts index e31cb98661..fcae511bae 100644 --- a/codelabs/engine/app/build.gradle.kts +++ b/codelabs/engine/app/build.gradle.kts @@ -4,48 +4,51 @@ plugins { } android { - compileSdk = 31 + namespace = "com.google.android.fhir.codelabs.engine" + compileSdk = 34 defaultConfig { applicationId = "com.google.android.fhir.codelabs.engine" minSdk = 24 - targetSdk = 31 + targetSdk = 34 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - - buildFeatures { viewBinding = true } buildTypes { release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" + buildFeatures { + buildConfig = true + viewBinding = true } - packaging { - resources.excludes.addAll(listOf("META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt")) + compileOptions { + // Flag to enable support for the new language APIs + // See https://developer.android.com/studio/write/java8-support + isCoreLibraryDesugaringEnabled = true } + + packaging { resources.excludes.addAll(listOf("META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt")) } + kotlin { jvmToolchain(11) } } dependencies { - implementation("androidx.core:core-ktx:1.7.0") - implementation("androidx.appcompat:appcompat:1.4.0") - implementation("com.google.android.material:material:1.4.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.2") - implementation("androidx.work:work-runtime-ktx:2.7.1") - - testImplementation("junit:junit:4.+") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - - implementation("com.google.android.fhir:engine:0.1.0-beta03") - implementation("androidx.fragment:fragment-ktx:1.5.5") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") + + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.work:work-runtime-ktx:2.8.1") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + + implementation("com.google.android.fhir:engine:0.1.0-beta04") + implementation("androidx.fragment:fragment-ktx:1.6.1") } diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt index 4150da3492..9e0d322c46 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt @@ -20,17 +20,21 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter +import com.google.android.fhir.SearchResult import com.google.android.fhir.codelabs.engine.databinding.PatientListItemViewBinding import org.hl7.fhir.r4.model.Patient class PatientItemRecyclerViewAdapter : - ListAdapter(PatientItemDiffCallback()) { + ListAdapter, PatientItemViewHolder>(PatientItemDiffCallback()) { - class PatientItemDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Patient, newItem: Patient) = oldItem.id == newItem.id + class PatientItemDiffCallback : DiffUtil.ItemCallback>() { + override fun areItemsTheSame(oldItem: SearchResult, newItem: SearchResult) = + oldItem.equals(newItem) - override fun areContentsTheSame(oldItem: Patient, newItem: Patient) = - oldItem.equalsDeep(newItem) + override fun areContentsTheSame( + oldItem: SearchResult, + newItem: SearchResult, + ) = oldItem.equals(newItem) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PatientItemViewHolder { 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..f37eddfb5b 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 @@ -18,6 +18,7 @@ package com.google.android.fhir.codelabs.engine import android.widget.TextView import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.SearchResult import com.google.android.fhir.codelabs.engine.databinding.PatientListItemViewBinding import org.hl7.fhir.r4.model.Patient @@ -28,10 +29,12 @@ class PatientItemViewHolder(binding: PatientListItemViewBinding) : private val genderTextView: TextView = binding.gender private val cityTextView = binding.city - fun bind(patientItem: Patient) { + fun bind(patientItem: SearchResult) { nameTextView.text = - patientItem.name.first().let { it.given.joinToString(separator = " ") + " " + it.family } - genderTextView.text = patientItem.gender.display - cityTextView.text = patientItem.address.singleOrNull()?.city + patientItem.resource.name.first().let { + it.given.joinToString(separator = " ") + " " + it.family + } + genderTextView.text = patientItem.resource.gender.display + cityTextView.text = patientItem.resource.address.singleOrNull()?.city } } diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt index 0042ff52b7..673623e3cb 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.android.fhir.SearchResult import com.google.android.fhir.search.Order import com.google.android.fhir.search.search import com.google.android.fhir.sync.SyncJobStatus @@ -35,7 +36,7 @@ class PatientListViewModel(application: Application) : AndroidViewModel(applicat val pollState: Flow get() = _pollState - val liveSearchedPatients = MutableLiveData>() + val liveSearchedPatients = MutableLiveData>>() init { updatePatientList { getSearchResults() } @@ -57,13 +58,13 @@ class PatientListViewModel(application: Application) : AndroidViewModel(applicat * client every time search query changes or data-sync is completed. */ private fun updatePatientList( - search: suspend () -> List, + search: suspend () -> List>, ) { viewModelScope.launch { liveSearchedPatients.value = search() } } - private suspend fun getSearchResults(): List { - val patients: MutableList = mutableListOf() + private suspend fun getSearchResults(): List> { + val patients: MutableList> = mutableListOf() FhirApplication.fhirEngine(this.getApplication()) .search { sort(Patient.GIVEN, Order.ASCENDING) } .let { patients.addAll(it) } diff --git a/codelabs/engine/build.gradle.kts b/codelabs/engine/build.gradle.kts index 77acf04960..dcd615f0cb 100644 --- a/codelabs/engine/build.gradle.kts +++ b/codelabs/engine/build.gradle.kts @@ -6,8 +6,8 @@ buildscript { gradlePluginPortal() } dependencies { - classpath("com.android.tools.build:gradle:7.0.4") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10") + classpath("com.android.tools.build:gradle:8.1.2") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle.kts files diff --git a/codelabs/engine/gradle/wrapper/gradle-wrapper.properties b/codelabs/engine/gradle/wrapper/gradle-wrapper.properties index b2a400b0e0..da1db5f04e 100644 --- a/codelabs/engine/gradle/wrapper/gradle-wrapper.properties +++ b/codelabs/engine/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jan 28 09:30:50 GMT 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists From cd52ba6c31d0d9457370b39f18c711b04ad0ab4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 12:29:56 +0100 Subject: [PATCH 09/69] Bump styfle/cancel-workflow-action from 0.7.0 to 0.12.0 (#2250) Bumps [styfle/cancel-workflow-action](https://github.com/styfle/cancel-workflow-action) from 0.7.0 to 0.12.0. - [Release notes](https://github.com/styfle/cancel-workflow-action/releases) - [Commits](https://github.com/styfle/cancel-workflow-action/compare/0.7.0...0.12.0) --- updated-dependencies: - dependency-name: styfle/cancel-workflow-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e30b28800f..e4b458fc51 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,7 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Cancel previous - uses: styfle/cancel-workflow-action@0.7.0 + uses: styfle/cancel-workflow-action@0.12.0 with: access_token: ${{ github.token }} From 8ed4bb352eab288d0a6f3abd461ba9ffb9f55f64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 13:51:44 +0100 Subject: [PATCH 10/69] Bump actions/upload-artifact from 2 to 3 (#2249) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4b458fc51..29fbc70ecd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,7 @@ jobs: - name: Release artifacts to local repo run: ./gradlew publishReleasePublicationToCIRepository --scan - name: Upload maven repo - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: maven-repository path: build/ci-repo @@ -76,7 +76,7 @@ jobs: # Upload the build dir for all the modules for diagnosis - name: Upload build dir if: ${{ failure() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: build path: build.zip From 82f71b7ac8c3f2e404769ab577d11dd4f2815842 Mon Sep 17 00:00:00 2001 From: aditya-07 Date: Wed, 18 Oct 2023 13:33:40 +0530 Subject: [PATCH 11/69] Update the version of resource after updates are downloaded from the server (#2272) * Update the version of resource after updates are downloaded from the server * Review changes: Added tests and refactored code * Review comments updates --- .../com/google/android/fhir/MoreResources.kt | 4 +- .../android/fhir/db/impl/DatabaseImpl.kt | 2 +- .../android/fhir/db/impl/dao/ResourceDao.kt | 82 +++++++++++------- .../android/fhir/impl/FhirEngineImplTest.kt | 84 +++++++++++++++++++ 4 files changed, 141 insertions(+), 31 deletions(-) diff --git a/engine/src/main/java/com/google/android/fhir/MoreResources.kt b/engine/src/main/java/com/google/android/fhir/MoreResources.kt index 1511e5daa1..f444acbe19 100644 --- a/engine/src/main/java/com/google/android/fhir/MoreResources.kt +++ b/engine/src/main/java/com/google/android/fhir/MoreResources.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ fun getResourceClass(resourceType: String): Class { return Class.forName(R4_RESOURCE_PACKAGE_PREFIX + className) as Class } -internal val Resource.versionId +internal val Resource.versionId: String? get() = meta.versionId internal val Resource.lastUpdated 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 a5cf77719c..acb3572325 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 @@ -140,7 +140,7 @@ internal class DatabaseImpl( resources.forEach { val timeOfLocalChange = Instant.now() val oldResourceEntity = selectEntity(it.resourceType, it.logicalId) - resourceDao.update(it, timeOfLocalChange) + resourceDao.applyLocalUpdate(it, timeOfLocalChange) localChangeDao.addUpdate(oldResourceEntity, it, timeOfLocalChange) } } 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 d7f25d388f..b4213abef3 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 @@ -58,43 +58,69 @@ internal abstract class ResourceDao { lateinit var iParser: IParser lateinit var resourceIndexer: ResourceIndexer - open suspend fun update(resource: Resource, timeOfLocalChange: Instant?) { + /** + * Updates the resource in the [ResourceEntity] and adds indexes as a result of changes made on + * device. + * + * @param [resource] the resource with local (on device) updates + * @param [timeOfLocalChange] time when the local change was made + */ + suspend fun applyLocalUpdate(resource: Resource, timeOfLocalChange: Instant?) { getResourceEntity(resource.logicalId, resource.resourceType)?.let { - // In case the resource has lastUpdated meta data, use it, otherwise use the old value. - val lastUpdatedRemote: Date? = resource.meta.lastUpdated val entity = it.copy( serializedResource = iParser.encodeResourceToString(resource), lastUpdatedLocal = timeOfLocalChange, - lastUpdatedRemote = lastUpdatedRemote?.toInstant() ?: it.lastUpdatedRemote, ) - // The foreign key in Index entity tables is set with cascade delete constraint and - // insertResource has REPLACE conflict resolution. So, when we do an insert to update the - // resource, it deletes old resource and corresponding index entities (based on foreign key - // constrain) before inserting the new resource. - insertResource(entity) - val index = - ResourceIndices.Builder(resourceIndexer.index(resource)) - .apply { - timeOfLocalChange?.let { - addDateTimeIndex( - createLocalLastUpdatedIndex( - resource.resourceType, - InstantType(Date.from(timeOfLocalChange)), - ), - ) - } - lastUpdatedRemote?.let { date -> - addDateTimeIndex(createLastUpdatedIndex(resource.resourceType, InstantType(date))) - } - } - .build() - updateIndicesForResource(index, resource.resourceType, it.resourceUuid) + updateChanges(entity, resource) } ?: throw ResourceNotFoundException(resource.resourceType.name, resource.id) } - open suspend fun insertAllRemote(resources: List): List { + /** + * Updates the resource in the [ResourceEntity] and adds indexes as a result of downloading the + * resource from server. + * + * @param [resource] the resource with the remote(server) updates + */ + private suspend fun applyRemoteUpdate(resource: Resource) { + getResourceEntity(resource.logicalId, resource.resourceType)?.let { + val entity = + it.copy( + serializedResource = iParser.encodeResourceToString(resource), + lastUpdatedRemote = resource.meta.lastUpdated?.toInstant(), + versionId = resource.versionId, + ) + updateChanges(entity, resource) + } + ?: throw ResourceNotFoundException(resource.resourceType.name, resource.id) + } + + private suspend fun updateChanges(entity: ResourceEntity, resource: Resource) { + // The foreign key in Index entity tables is set with cascade delete constraint and + // insertResource has REPLACE conflict resolution. So, when we do an insert to update the + // resource, it deletes old resource and corresponding index entities (based on foreign key + // constrain) before inserting the new resource. + insertResource(entity) + val index = + ResourceIndices.Builder(resourceIndexer.index(resource)) + .apply { + entity.lastUpdatedLocal?.let { instant -> + addDateTimeIndex( + createLocalLastUpdatedIndex(resource.resourceType, InstantType(Date.from(instant))), + ) + } + entity.lastUpdatedRemote?.let { instant -> + addDateTimeIndex( + createLastUpdatedIndex(resource.resourceType, InstantType(Date.from(instant))), + ) + } + } + .build() + updateIndicesForResource(index, resource.resourceType, entity.resourceUuid) + } + + suspend fun insertAllRemote(resources: List): List { return resources.map { resource -> insertRemoteResource(resource) } } @@ -189,7 +215,7 @@ internal abstract class ResourceDao { private suspend fun insertRemoteResource(resource: Resource): UUID { val existingResourceEntity = getResourceEntity(resource.logicalId, resource.resourceType) if (existingResourceEntity != null) { - update(resource, existingResourceEntity.lastUpdatedLocal) + applyRemoteUpdate(resource) return existingResourceEntity.resourceUuid } return insertResource(resource, null) 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 a77a6b76f4..4cd475fd7e 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 @@ -24,6 +24,7 @@ import com.google.android.fhir.LocalChange.Type import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get +import com.google.android.fhir.lastUpdated import com.google.android.fhir.logicalId import com.google.android.fhir.search.LOCAL_LAST_UPDATED_PARAM import com.google.android.fhir.search.search @@ -36,7 +37,9 @@ import com.google.android.fhir.sync.upload.UploadSyncResult import com.google.android.fhir.testing.assertResourceEquals import com.google.android.fhir.testing.assertResourceNotEquals import com.google.android.fhir.testing.readFromFile +import com.google.android.fhir.versionId import com.google.common.truth.Truth.assertThat +import java.time.Instant import java.util.Date import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf @@ -592,6 +595,87 @@ class FhirEngineImplTest { assertResourceEquals(fhirEngine.get("original-002"), localChange) } + @Test + fun `syncDownload ResourceEntity should have the latest versionId and lastUpdated from server`() = + runBlocking { + val originalPatient = + Patient().apply { + id = "original-002" + meta = + Meta().apply { + versionId = "1" + lastUpdated = Date.from(Instant.parse("2022-12-02T10:15:30.00Z")) + } + addName( + HumanName().apply { + family = "Stark" + addGiven("Tony") + }, + ) + } + // First sync + fhirEngine.syncDownload(AcceptLocalConflictResolver) { flowOf((listOf((originalPatient)))) } + + val updatedPatient = + originalPatient.copy().apply { + meta = + Meta().apply { + versionId = "2" + lastUpdated = Date.from(Instant.parse("2022-12-03T10:15:30.00Z")) + } + addAddress(Address().apply { country = "USA" }) + } + + // Sync to get updates from server + fhirEngine.syncDownload(AcceptLocalConflictResolver) { flowOf((listOf(updatedPatient))) } + + val result = services.database.selectEntity(ResourceType.Patient, "original-002") + assertThat(result.versionId).isEqualTo(updatedPatient.versionId) + assertThat(result.lastUpdatedRemote).isEqualTo(updatedPatient.lastUpdated) + } + + @Test + fun `syncDownload LocalChangeEntity should have the latest versionId from server`() = + runBlocking { + val originalPatient = + Patient().apply { + id = "original-002" + meta = + Meta().apply { + versionId = "1" + lastUpdated = Date.from(Instant.parse("2022-12-02T10:15:30.00Z")) + } + addName( + HumanName().apply { + family = "Stark" + addGiven("Tony") + }, + ) + } + // First sync + fhirEngine.syncDownload(AcceptLocalConflictResolver) { flowOf((listOf((originalPatient)))) } + + val localChange = + originalPatient.copy().apply { addAddress(Address().apply { city = "Malibu" }) } + fhirEngine.update(localChange) + + val updatedPatient = + originalPatient.copy().apply { + meta = + Meta().apply { + versionId = "2" + lastUpdated = Date.from(Instant.parse("2022-12-03T10:15:30.00Z")) + } + addAddress(Address().apply { country = "USA" }) + } + + // Sync to get updates from server + fhirEngine.syncDownload(AcceptLocalConflictResolver) { flowOf((listOf(updatedPatient))) } + + val result = fhirEngine.getLocalChanges(ResourceType.Patient, "original-002").first() + assertThat(result.versionId).isEqualTo(updatedPatient.versionId) + } + @Test fun `create should allow patient search with LOCAL_LAST_UPDATED_PARAM`(): Unit = runBlocking { val patient = Patient().apply { id = "patient-id-create" } From dbbc7ed88e4552c37891ab7eb5928553aaf1eac0 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Wed, 18 Oct 2023 13:38:07 +0100 Subject: [PATCH 12/69] Bump up engine version to beta05 (#2275) Co-authored-by: aditya-07 --- buildSrc/src/main/kotlin/Releases.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index 3aadfdf83b..19b6cec415 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 = "0.1.0-beta04" + override val version = "0.1.0-beta05" override val name = "Android FHIR Engine Library" } From 56f02539c07b13a69429daaf1af1becad0a783e2 Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Wed, 18 Oct 2023 19:00:08 +0530 Subject: [PATCH 13/69] Update m3 typography in the demo app. (#2215) * Update material3 theme. * Support m3 night mode. * Address review comments. * Address review comments. * Update m3 typography. --------- Co-authored-by: Santosh Pingle --- demo/src/main/res/values/styles.xml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/demo/src/main/res/values/styles.xml b/demo/src/main/res/values/styles.xml index dcc7451ccb..3096ca4f64 100644 --- a/demo/src/main/res/values/styles.xml +++ b/demo/src/main/res/values/styles.xml @@ -67,7 +67,7 @@ - @@ -79,16 +79,21 @@ From cf838c6c9e0094567a53e1e63e4484b3d03b8e49 Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Thu, 19 Oct 2023 20:11:15 +0530 Subject: [PATCH 14/69] Default m3 style to survey button. (#2225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update material3 theme. * Support m3 night mode. * Address review comments. * Address review comments. * Apply m3 default style to survey button. * Use default typography. * Fix broken dependabot.yaml syntax (fixes #2195) (#2234) * Moves commons to Hapi 6.8, removes caffeine and forces guava-android (#2241) * Bump up common library version to 0.1.0-alpha05 (#2243) * Update material3 theme in demo app. (#2211) * Update material3 theme. * Support m3 night mode. * Address review comments. * Address review comments. * Change the color order. --------- Co-authored-by: Santosh Pingle * Fix broken dependabot.yaml syntax again (re. #2195) (#2242) * SdkSuppress FhirSyncWorkerBenchmark oneTimeSync_50patients (re. #2235) (#2236) * Allow custom package server in Knowledge Manager (#2244) * Allow custom package server in Knowledge Manager * Fix downloader test * Fix workflow library * Update knowledge/src/main/java/com/google/android/fhir/knowledge/files/NpmFileManager.kt Co-authored-by: aditya-07 * Fix build * Add try catch for package download --------- Co-authored-by: aditya-07 * Bump up the version of knowledge manager (#2258) * Migrates engine to HAPI 6.8 while forcing Workflow to stay on 6.0 (#2245) * Migrates engine to HAPI 6.8 while forcing Workflow to stay on 6.0 * comment * Migrates Workflow to CQL Evaluator 3.0.0-PRE9-SNAPSHOT (#2259) * Updates dependencies * Updates test cases * Removes moxy and antlr from dependencies. * Migrates the workflow to the new API * Migrates workflow testing to the new API * Fixes workflow benchmark module * Fixes workflow benchmark module * Adds contents.txt file to all test repos so it can be loaded on androidTest * Normalizes path before trying to open files * Uses contents.txt when running tests on Android * Spotless * Updating Json and XML comparisons to pass when elements are just out of order. * Ignores testANCDT17 for now * LaunchContext for initialExpression (#2025) * Use launchContext for initialExpression * Test ResourceMapper * Fix test * Fix launchContexts for demo app when editing patient * spotlessApply * WIP * Revert "WIP" This reverts commit d2d53a2d5c7a92a503468a5b26f66b9691aa348e. * spotlessApply * Fix test * Refactor validateLaunchContextExtension * Remove QuestionnaireLaunchContextSet enum class * Rename vars and functions * Add code comment for MoreResourceTypes.kt * Unit testing * Fix post-merge-conflict * spotlessApply * Address review * spotlessApply * Revert un-intended changes * Fix failing checks * Remove check of must contain 2 sub-extensions * Update Kdoc * Revert * validate launch context when using populate public API * Renames patientId param to subject to: (#2263) 1. clarify the need to send a full reference "type/id" instead of just the ID 2. match the evaluator's name * Follow up PR of 2025 (#2265) * Use launchContext for initialExpression * Test ResourceMapper * Fix test * Fix launchContexts for demo app when editing patient * spotlessApply * WIP * Revert "WIP" This reverts commit d2d53a2d5c7a92a503468a5b26f66b9691aa348e. * spotlessApply * Fix test * Refactor validateLaunchContextExtension * Remove QuestionnaireLaunchContextSet enum class * Rename vars and functions * Add code comment for MoreResourceTypes.kt * Unit testing * Fix post-merge-conflict * spotlessApply * Address review * spotlessApply * Revert un-intended changes * Fix failing checks * Remove check of must contain 2 sub-extensions * Update Kdoc * Revert * validate launch context when using populate public API * Address Jing's Review - https://github.com/google/android-fhir/pull/2025#discussion_r1358711754 - https://github.com/google/android-fhir/pull/2025#discussion_r1358714996 * Bump com.squareup:kotlinpoet from 1.12.0 to 1.14.2 (#2255) Bumps [com.squareup:kotlinpoet](https://github.com/square/kotlinpoet) from 1.12.0 to 1.14.2. - [Release notes](https://github.com/square/kotlinpoet/releases) - [Changelog](https://github.com/square/kotlinpoet/blob/main/docs/changelog.md) - [Commits](https://github.com/square/kotlinpoet/compare/1.12.0...1.14.2) --- updated-dependencies: - dependency-name: com.squareup:kotlinpoet dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jing Tang * Bump androidx.build.gradle.gcpbuildcache (#2266) Bumps androidx.build.gradle.gcpbuildcache from 1.0.0-beta01 to 1.0.0-beta05. --- updated-dependencies: - dependency-name: androidx.build.gradle.gcpbuildcache dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> * Bump com.spotify.ruler:ruler-gradle-plugin from 1.2.1 to 1.4.0 (#2253) Bumps [com.spotify.ruler:ruler-gradle-plugin](https://github.com/spotify/ruler) from 1.2.1 to 1.4.0. - [Release notes](https://github.com/spotify/ruler/releases) - [Changelog](https://github.com/spotify/ruler/blob/main/changelog.md) - [Commits](https://github.com/spotify/ruler/compare/v1.2.1...v1.4.0) --- updated-dependencies: - dependency-name: com.spotify.ruler:ruler-gradle-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump com.diffplug.spotless:spotless-plugin-gradle from 6.21.0 to 6.22.0 (#2252) Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.21.0 to 6.22.0. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/gradle/6.21.0...gradle/6.22.0) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/checkout from 3 to 4 (#2248) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jing Tang * Update dependencies in engine colab (#2268) * Update dependencies in engine colab * update datacapture * Bump styfle/cancel-workflow-action from 0.7.0 to 0.12.0 (#2250) Bumps [styfle/cancel-workflow-action](https://github.com/styfle/cancel-workflow-action) from 0.7.0 to 0.12.0. - [Release notes](https://github.com/styfle/cancel-workflow-action/releases) - [Commits](https://github.com/styfle/cancel-workflow-action/compare/0.7.0...0.12.0) --- updated-dependencies: - dependency-name: styfle/cancel-workflow-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/upload-artifact from 2 to 3 (#2249) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update the version of resource after updates are downloaded from the server (#2272) * Update the version of resource after updates are downloaded from the server * Review changes: Added tests and refactored code * Review comments updates --------- Signed-off-by: dependabot[bot] Co-authored-by: Santosh Pingle Co-authored-by: Michael Vorburger ⛑️ Co-authored-by: Vitor Pamplona Co-authored-by: Jing Tang Co-authored-by: aditya-07 Co-authored-by: aditya-07 Co-authored-by: Fikri Milano Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- demo/src/main/res/layout/patient_details_header.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/demo/src/main/res/layout/patient_details_header.xml b/demo/src/main/res/layout/patient_details_header.xml index c7699318c3..84d8c3e252 100644 --- a/demo/src/main/res/layout/patient_details_header.xml +++ b/demo/src/main/res/layout/patient_details_header.xml @@ -70,11 +70,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="5dp" - android:paddingStart="2dp" - android:drawablePadding="2dp" - android:drawableStart="@drawable/ic_baseline_add_24" android:text="@string/complete_survey" - android:textAppearance="?attr/textAppearanceBody2" /> From c07ccc74d6b852c2a7d18c400d280e9c36b434da Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 19 Oct 2023 12:13:23 -0400 Subject: [PATCH 15/69] Overrides Ids when installing an IG (#2264) * 1. Overrides Ids when installing Knowledge Artifacts 2. Updates files to match id 3. Updates test case framework to not rely in Ids, because they will change 4. Updates FhirOperator interface to offer a way to not use the internal Id when selecting a PlanDefinition 5. Updates test cases to match the new output ids. * Deprecating generateCarePlan with planDefinitionID * Renames importResource -> indexResource * we actually have the guarantee that ids are always unique in the resource metadata entity db table... so this function can actually be simplified to remove the resource type parameter. * Removes unnecessary function --- .../fhir/knowledge/KnowledgeManager.kt | 27 ++++++++++--- .../fhir/knowledge/db/dao/KnowledgeDao.kt | 16 ++++---- .../fhir/knowledge/KnowledgeManagerTest.kt | 19 +++++++-- .../care_plan.json | 12 +++--- .../med-request/med_request_careplan.json | 10 ++--- .../android/fhir/workflow/FhirOperator.kt | 40 +++++++++++++++++++ .../android/fhir/workflow/FhirOperatorTest.kt | 21 ++++++---- 7 files changed, 112 insertions(+), 33 deletions(-) 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 60f1ad1845..83c3d95990 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 @@ -28,9 +28,11 @@ import com.google.android.fhir.knowledge.npm.NpmPackageDownloader import com.google.android.fhir.knowledge.npm.OkHttpNpmPackageDownloader import java.io.File import java.io.FileInputStream +import java.io.FileOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.hl7.fhir.instance.model.api.IBaseResource +import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.MetadataResource import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -95,7 +97,13 @@ internal constructor( try { val resource = jsonParser.parseResource(FileInputStream(file)) if (resource is Resource) { - importResource(igId, resource, file) + val newId = indexResourceFile(igId, resource, file) + resource.setId(IdType(resource.resourceType.name, newId)) + + // Overrides the Id in the file + FileOutputStream(file).use { + it.write(jsonParser.encodeResourceToString(resource).toByteArray()) + } } else { Timber.d("Unable to import file: %file") } @@ -124,7 +132,7 @@ internal constructor( url != null && version != null -> listOfNotNull(knowledgeDao.getResourceWithUrlAndVersion(url, version)) url != null -> listOfNotNull(knowledgeDao.getResourceWithUrl(url)) - id != null -> listOfNotNull(knowledgeDao.getResourceWithUrlLike("%$id")) + id != null -> listOfNotNull(knowledgeDao.getResourcesWithId(id.toLong())) name != null && version != null -> listOfNotNull(knowledgeDao.getResourcesWithNameAndVersion(resType, name, version)) name != null -> knowledgeDao.getResourcesWithName(resType, name) @@ -154,11 +162,19 @@ internal constructor( } } when (resource) { - is Resource -> importResource(igId, resource, file) + is Resource -> { + val newId = indexResourceFile(igId, resource, file) + resource.setId(IdType(resource.resourceType.name, newId)) + + // Overrides the Id in the file + FileOutputStream(file).use { + it.write(jsonParser.encodeResourceToString(resource).toByteArray()) + } + } } } - private suspend fun importResource(igId: Long?, resource: Resource, file: File) { + private suspend fun indexResourceFile(igId: Long?, resource: Resource, file: File): Long { val metadataResource = resource as? MetadataResource val res = ResourceMetadataEntity( @@ -169,7 +185,8 @@ internal constructor( metadataResource?.version, file, ) - knowledgeDao.insertResource(igId, res) + + return knowledgeDao.insertResource(igId, res) } private fun loadResource(resourceEntity: ResourceMetadataEntity): IBaseResource { diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/db/dao/KnowledgeDao.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/db/dao/KnowledgeDao.kt index a76390c879..bc5d937b78 100644 --- a/knowledge/src/main/java/com/google/android/fhir/knowledge/db/dao/KnowledgeDao.kt +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/db/dao/KnowledgeDao.kt @@ -35,7 +35,7 @@ abstract class KnowledgeDao { internal open suspend fun insertResource( implementationGuideId: Long?, resource: ResourceMetadataEntity, - ) { + ): Long { val resourceMetadata = if (resource.url != null && resource.version != null) { getResourceWithUrlAndVersion(resource.url, resource.version) @@ -48,6 +48,7 @@ abstract class KnowledgeDao { // exception if they are different. val resourceMetadataId = resourceMetadata?.resourceMetadataId ?: insert(resource) insert(ImplementationGuideResourceMetadataEntity(0, implementationGuideId, resourceMetadataId)) + return resourceMetadataId } @Transaction @@ -94,12 +95,6 @@ abstract class KnowledgeDao { url: String, ): ResourceMetadataEntity? - // Remove after https://github.com/google/android-fhir/issues/1920 - @Query("SELECT * from ResourceMetadataEntity WHERE url LIKE :urlPart") - internal abstract suspend fun getResourceWithUrlLike( - urlPart: String, - ): ResourceMetadataEntity? - @Query( "SELECT * from ResourceMetadataEntity WHERE resourceType = :resourceType AND name = :name", ) @@ -108,6 +103,13 @@ abstract class KnowledgeDao { name: String?, ): List + @Query( + "SELECT * from ResourceMetadataEntity WHERE resourceMetadataId = :id", + ) + internal abstract suspend fun getResourcesWithId( + id: Long?, + ): ResourceMetadataEntity? + @Query( "SELECT * from ResourceMetadataEntity WHERE resourceType = :resourceType AND name = :name AND version = :version", ) diff --git a/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt b/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt index c28c651767..2b1d187194 100644 --- a/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt +++ b/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt @@ -113,14 +113,14 @@ internal class KnowledgeManagerTest { fun `inserting a library of a different version creates new entry`() = runTest { val libraryAOld = Library().apply { - id = "defaultA" + id = "Library/defaultA-A.1.0.0" name = "defaultA" url = "www.exampleA.com" version = "A.1.0.0" } val libraryANew = Library().apply { - id = "defaultA" + id = "Library/defaultA-A.1.0.1" name = "defaultA" url = "www.exampleA.com" version = "A.1.0.1" @@ -131,6 +131,18 @@ internal class KnowledgeManagerTest { val resources = knowledgeDb.knowledgeDao().getResources() assertThat(resources).hasSize(2) + + val resourceA100 = + knowledgeManager + .loadResources(resourceType = "Library", name = "defaultA", version = "A.1.0.0") + .single() + assertThat(resourceA100.idElement.toString()).isEqualTo("Library/1") + + val resourceA101 = + knowledgeManager + .loadResources(resourceType = "Library", name = "defaultA", version = "A.1.0.1") + .single() + assertThat(resourceA101.idElement.toString()).isEqualTo("Library/2") } fun `installing from npmPackageManager`() = runTest { @@ -153,7 +165,8 @@ internal class KnowledgeManagerTest { } private fun writeToFile(library: Library): File { - return File(context.filesDir, library.name).apply { + return File(context.filesDir, library.id).apply { + this.parentFile?.mkdirs() writeText(jsonParser.encodeResourceToString(library)) } } diff --git a/workflow-testing/src/main/resources/plan-definition/cql-applicability-condition/care_plan.json b/workflow-testing/src/main/resources/plan-definition/cql-applicability-condition/care_plan.json index 11924add40..892663c8e0 100644 --- a/workflow-testing/src/main/resources/plan-definition/cql-applicability-condition/care_plan.json +++ b/workflow-testing/src/main/resources/plan-definition/cql-applicability-condition/care_plan.json @@ -1,9 +1,9 @@ { "resourceType": "CarePlan", - "id": "Plan-Definition-Example", + "id": "17", "contained": [ { "resourceType": "RequestGroup", - "id": "Plan-Definition-Example", + "id": "17", "instantiatesCanonical": [ "http://example.com/PlanDefinition/Plan-Definition-Example" ], "status": "draft", "intent": "proposal", @@ -20,12 +20,12 @@ } } ], "resource": { - "reference": "Task/Activity-Example" + "reference": "Task/16" } } ] }, { "resourceType": "Task", - "id": "Activity-Example", + "id": "16", "extension": [ { "url": "http://hl7.org/fhir/aphl/StructureDefinition/condition", "valueExpression": { @@ -35,7 +35,7 @@ } ], "instantiatesCanonical": "http://example.com/ActivityDefinition/Activity-Example", "basedOn": [ { - "reference": "#RequestGroup/Plan-Definition-Example", + "reference": "#RequestGroup/17", "type": "RequestGroup" } ], "status": "draft", @@ -53,7 +53,7 @@ }, "activity": [ { "reference": { - "reference": "#RequestGroup/Plan-Definition-Example" + "reference": "#RequestGroup/17" } } ] } \ No newline at end of file diff --git a/workflow-testing/src/main/resources/plan-definition/med-request/med_request_careplan.json b/workflow-testing/src/main/resources/plan-definition/med-request/med_request_careplan.json index cf71f8ef1b..117d63447d 100644 --- a/workflow-testing/src/main/resources/plan-definition/med-request/med_request_careplan.json +++ b/workflow-testing/src/main/resources/plan-definition/med-request/med_request_careplan.json @@ -1,10 +1,10 @@ { "resourceType": "CarePlan", - "id": "MedRequest-Example", + "id": "17", "contained": [ { "resourceType": "RequestGroup", - "id": "MedRequest-Example", + "id": "17", "instantiatesCanonical": [ "http://localhost/PlanDefinition/MedRequest-Example" ], @@ -18,14 +18,14 @@ "id": "medication-action-1", "title": "Administer Medication 1", "resource": { - "reference": "MedicationRequest/MedicationRequest-1" + "reference": "MedicationRequest/16" } } ] }, { "resourceType": "MedicationRequest", - "id": "MedicationRequest-1", + "id": "16", "status": "draft", "intent": "order", "medicationCodeableConcept": { @@ -50,7 +50,7 @@ "activity": [ { "reference": { - "reference": "#RequestGroup/MedRequest-Example" + "reference": "#RequestGroup/17" } } ] diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt index 3fa8e21923..8c595e22b7 100644 --- a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt +++ b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt @@ -185,10 +185,19 @@ internal constructor( * from a worker thread or it may throw [BlockingMainThreadException] exception. */ @WorkerThread + @Deprecated( + "Use generateCarePlan with the planDefinition's url instead.", + ReplaceWith("this.generateCarePlan(CanonicalType, String)"), + ) fun generateCarePlan(planDefinitionId: String, subject: String): IBaseResource { return generateCarePlan(planDefinitionId, subject, encounterId = null) } + @WorkerThread + fun generateCarePlan(planDefinition: CanonicalType, subject: String): IBaseResource { + return generateCarePlan(planDefinition, subject, encounterId = null) + } + /** * Generates a [CarePlan] based on the provided inputs. * @@ -196,6 +205,10 @@ internal constructor( * from a worker thread or it may throw [BlockingMainThreadException] exception. */ @WorkerThread + @Deprecated( + "Use generateCarePlan with the planDefinition's url instead.", + ReplaceWith("this.generateCarePlan(CanonicalType, String, String)"), + ) fun generateCarePlan( planDefinitionId: String, subject: String, @@ -222,6 +235,33 @@ internal constructor( ) as IBaseResource } + @WorkerThread + fun generateCarePlan( + planDefinition: CanonicalType, + subject: String, + encounterId: String?, + ): IBaseResource { + return planDefinitionProcessor.apply( + /* id = */ null, + /* canonical = */ planDefinition, + /* planDefinition = */ null, + /* subject = */ subject, + /* encounterId = */ encounterId, + /* practitionerId = */ null, + /* organizationId = */ null, + /* userType = */ null, + /* userLanguage = */ null, + /* userTaskContext = */ null, + /* setting = */ null, + /* settingContext = */ null, + /* parameters = */ null, + /* useServerData = */ null, + /* bundle = */ null, + /* prefetchData = */ null, + libraryProcessor, + ) as IBaseResource + } + class Builder(private val applicationContext: Context) { private var fhirContext: FhirContext? = null private var fhirEngine: FhirEngine? = null diff --git a/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt b/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt index 95f4ced23a..c534d728b4 100644 --- a/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt +++ b/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt @@ -32,6 +32,7 @@ import java.lang.IllegalArgumentException import java.util.TimeZone import kotlin.reflect.KSuspendFunction1 import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CanonicalType import org.hl7.fhir.r4.model.Library import org.hl7.fhir.r4.model.MetadataResource import org.hl7.fhir.r4.model.Resource @@ -98,7 +99,10 @@ class FhirOperatorTest { assertThat( fhirOperator.generateCarePlan( - planDefinitionId = "plandefinition-RuleFilters-1.0.0", + planDefinition = + CanonicalType( + "http://hl7.org/fhir/us/ecr/PlanDefinition/plandefinition-RuleFilters-1.0.0", + ), subject = "Patient/Reportable", encounterId = "reportable-encounter", ), @@ -113,12 +117,10 @@ class FhirOperatorTest { val carePlan = fhirOperator.generateCarePlan( - planDefinitionId = "MedRequest-Example", + planDefinition = CanonicalType("http://localhost/PlanDefinition/MedRequest-Example"), subject = "Patient/Patient-Example", ) - println(jsonParser.encodeResourceToString(carePlan)) - assertEquals( readResourceAsString("/plan-definition/med-request/med_request_careplan.json"), jsonParser.encodeResourceToString(carePlan), @@ -137,7 +139,7 @@ class FhirOperatorTest { val carePlan = fhirOperator.generateCarePlan( - planDefinitionId = "Plan-Definition-Example", + planDefinition = CanonicalType("http://example.com/PlanDefinition/Plan-Definition-Example"), subject = "Patient/Female-Patient-Example", ) @@ -287,11 +289,16 @@ class FhirOperatorTest { private fun writeToFile(resource: Resource): File { val fileName = if (resource is MetadataResource && resource.name != null) { - resource.name + if (resource.version != null) { + resource.name + "-" + resource.version + } else { + resource.name + } } else { - resource.idElement.idPart + resource.idElement.toString() } return File(context.filesDir, fileName).apply { + this.parentFile.mkdirs() writeText(jsonParser.encodeResourceToString(resource)) } } From a37cff9650fb1b5c0ddf9f4eaf84e8c2720200ed Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Fri, 20 Oct 2023 21:04:26 +0100 Subject: [PATCH 16/69] Update and expand engine codelab README (#2273) * Update and expand README * clean formatting * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md * Update codelabs/engine/README.md --------- Co-authored-by: Jing Tang --- codelabs/engine/README.md | 520 +++++++++++++++++++++++--------------- 1 file changed, 316 insertions(+), 204 deletions(-) diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md index fef62669b3..0c5d5dd160 100644 --- a/codelabs/engine/README.md +++ b/codelabs/engine/README.md @@ -28,88 +28,85 @@ upload any local changes to the server. * The sample code * Basic knowledge of Android development in Kotlin -If you haven't built Android apps before, you can -start by [building your first -app](https://developer.android.com/training/basics/firstapp). +If you haven't built Android apps before, you can start by +[building your first app](https://developer.android.com/training/basics/firstapp). ## Set up a local HAPI FHIR server with test data -[HAPI FHIR](https://hapifhir.io/hapi-fhir/) is a popular open-source FHIR -server. We will use a local HAPI FHIR server in our codelab for the Android app +[HAPI FHIR](https://hapifhir.io/hapi-fhir/) is a popular open source FHIR +server. We use a local HAPI FHIR server in our codelab for the Android app to connect to. ### Set up the local HAPI FHIR server -1. Run the following command in a terminal to get the latest image of HAPI - FHIR +1. Run the following command in a terminal to get the latest image of HAPI FHIR + ```shell + docker pull hapiproject/hapi:latest + ``` -```shell -docker pull hapiproject/hapi:latest -``` - -2. Create a HAPI FHIR container by either - using Docker Desktop to run the previously download image `hapiproject/hapi`, or - running the following command - -```shell -docker run -p 8080:8080 hapiproject/hapi:latest -``` - -Learn [more](https://github.com/hapifhir/hapi-fhir-jpaserver-starter#running-via-docker-hub). +1. Create a HAPI FHIR container by either using Docker Desktop to run the + previously download image `hapiproject/hapi`, or running the following + command + ```shell + docker run -p 8080:8080 hapiproject/hapi:latest + ``` + Learn + [more](https://github.com/hapifhir/hapi-fhir-jpaserver-starter#running-via-docker-hub). -3. Inspect the server by opening the URL `http://localhost:8080/` in a browser. - You should see the HAPI FHIR web interface. +1. Inspect the server by opening the URL `http://localhost:8080/` in a browser. + You should see the HAPI FHIR web interface. -![HAPI FHIR web interface](images/image4.png "HAPI FHIR web interface") + ![HAPI FHIR web interface](images/image4.png "HAPI FHIR web interface") ### Populate the local HAPI FHIR server with test data To test our application, we'll need some test data on the server. We'll use synthetic data generated by Synthea. -1. First, we need to download sample data from [synthea-samples]( - https://github.com/synthetichealth/synthea-sample-data/tree/master/downloads). - Download and unzip `synthea_sample_data_fhir_r4_sep2019.zip`. The un-zipped - sample data has numerous `.json` files, each being a transaction bundle for an - individual patient. +1. First, we need to download sample data from + [synthea-samples](https://github.com/synthetichealth/synthea-sample-data/tree/master/downloads). + Download and extract `synthea_sample_data_fhir_r4_sep2019.zip`. The + un-zipped + sample data has numerous `.json` files, each being a transaction bundle for + an individual patient. -2. We'll upload test data for three patients to the local HAPI FHIR server. - Run the following command in the directory containing JSON files +1. We'll upload test data for three patients to the local HAPI FHIR server. Run + the following command in the directory containing JSON files -```shell -curl -X POST -H "Content-Type: application/json" -d @./Aaron697_Brekke496_2fa15bc7-8866-461a-9000-f739e425860a.json http://localhost:8080/fhir/ -curl -X POST -H "Content-Type: application/json" -d @./Aaron697_Stiedemann542_41166989-975d-4d17-b9de-17f94cb3eec1.json http://localhost:8080/fhir/ -curl -X POST -H "Content-Type: application/json" -d @./Abby752_Kuvalis369_2b083021-e93f-4991-bf49-fd4f20060ef8.json http://localhost:8080/fhir/ -``` + ```shell + curl -X POST -H "Content-Type: application/json" -d @./Aaron697_Brekke496_2fa15bc7-8866-461a-9000-f739e425860a.json http://localhost:8080/fhir/ + curl -X POST -H "Content-Type: application/json" -d @./Aaron697_Stiedemann542_41166989-975d-4d17-b9de-17f94cb3eec1.json http://localhost:8080/fhir/ + curl -X POST -H "Content-Type: application/json" -d @./Abby752_Kuvalis369_2b083021-e93f-4991-bf49-fd4f20060ef8.json http://localhost:8080/fhir/ + ``` -3. To upload test data for all patients to the server, run +1. To upload test data for all patients to the server, run -```shell -for f in *.json; do curl -X POST -H "Content-Type: application/json" -d @$f http://localhost:8080/fhir/ ; done -``` + ```shell + for f in *.json; do curl -X POST -H "Content-Type: application/json" -d @$f http://localhost:8080/fhir/ ; done + ``` -However, this can take a long time to complete and is not necessary for the -codelab. + However, this can take a long time to complete and is not necessary for the + codelab. -4. Verify that the test data is available on the server by opening the URL - `http://localhost:8080/fhir/Patient/` in a browser. You should see the text - `HTTP 200 OK` and the `Response Body` section of the page containing patient - data in a FHIR Bundle as the search result with a `total` count. +1. Verify that the test data is available on the server by opening the URL + `http://localhost:8080/fhir/Patient/` in a browser. You should see the text + `HTTP 200 OK` and the `Response Body` section of the page containing patient + data in a FHIR Bundle as the search result with a `total` count. -![Test data on server](images/image5.png "Test data on server") + ![Test data on server](images/image5.png "Test data on server") ## Set up the Android app ### Download the Code -To download the code for this codelab, clone the Android FHIR SDK repo: `git -clone https://github.com/google/android-fhir.git` +To download the code for this codelab, clone the Android FHIR SDK repository: +`git clone https://github.com/google/android-fhir.git` The starter project for this codelab is located in `codelabs/engine`. ### Import the app into Android Studio -Let's start by importing the starter app into Android Studio. +We start by importing the starter app into Android Studio. Open Android Studio, select **Import Project (Gradle, Eclipse ADT, etc.)** and choose the `codelabs/engine/` folder from the source code that you have @@ -120,231 +117,347 @@ downloaded earlier. ### Sync your project with Gradle files For your convenience, the FHIR Engine Library dependencies have already been -add to the project. This allows you to integrate the FHIR Engine Library in your -app. Observe the following lines to the end of the -`app/build.gradle.kts` file of your project: +added to the project. This allows you to integrate the FHIR Engine Library in +your app. Observe the following lines to the end of the `app/build.gradle.kts` +file of your project: ```kotlin dependencies { // ... - implementation("com.google.android.fhir:engine:0.1.0-beta03") + implementation("com.google.android.fhir:engine:0.1.0-beta05") } ``` To be sure that all dependencies are available to your app, you should sync your project with gradle files at this point. -Select **Sync Project with Gradle Files** (![Gradle sync button](images/image3.png "Gradle sync button"))from the Android Studio -toolbar. You an also run the app again to check the dependencies are working -correctly. +Select **Sync Project with Gradle Files** +(![Gradle sync button](images/image3.png "Gradle sync button"))from the Android +Studio toolbar. You an also run the app again to check the dependencies are +working correctly. ### Run the starter app Now that you have imported the project into Android Studio, you are ready to run the app for the first time. -[Start the Android Studio emulator]( -https://developer.android.com/studio/run/emulator), and click Run -(![Run button](images/image2.png "Run button")) in the Android Studio -toolbar. +[Start the Android Studio emulator](https://developer.android.com/studio/run/emulator), +and click Run (![Run button](images/image2.png "Run button")) in the Android +Studio toolbar. Hello World app ## Create FHIR Engine instance -To use the FHIR Engine Library, you need an instance of FHIR Engine. It will be -the entry point of FHIR Engine APIs. - -1. Open `FhirApplication.kt` - (**app/src/main/java/com/google/android/fhir/codelabs/engine**). -2. In function `onCreate()`, add the following code to initialize FHIR Engine: +To incorporate the FHIR Engine into your Android app, you'll need to use the +FHIR Engine Library and initiate an instance of the FHIR Engine. The steps +outlined below will guide you through the process. + +1. Navigate to your Application class, which in this example is + `FhirApplication.kt`, located in + `app/src/main/java/com/google/android/fhir/codelabs/engine`. +1. Inside the `onCreate()` method, add the following code to initialize FHIR + Engine: + ```kotlin + FhirEngineProvider.init( + FhirEngineConfiguration( + enableEncryptionIfSupported = true, + RECREATE_AT_OPEN, + ServerConfiguration( + baseUrl = "http://10.0.2.2:8080/fhir/", + httpLogger = + HttpLogger( + HttpLogger.Configuration( + if (BuildConfig.DEBUG) HttpLogger.Level.BODY else HttpLogger.Level.BASIC, + ), + ) { + Log.d("App-HttpLog", it) + }, + ), + ), + ) + ``` + --- + + Notes: + * `enableEncryptionIfSupported`: Enables data encryption if the device + supports it. + * `RECREATE_AT_OPEN`: Determines the database error strategy. In + this case, it recreates the database if an error occurs upon opening. + * `baseUrl` in ServerConfiguration: This is the FHIR server's base URL. The + provided IP address `10.0.2.2` is specially reserved for localhost, + accessible from the Android emulator. Learn + [more](https://developer.android.com/studio/run/emulator-networking). + +1. In the `FhirApplication` class, add the following line to lazily instantiate + the FHIR Engine: `kotlin private val fhirEngine: FhirEngine by lazy { + FhirEngineProvider.getInstance(this) }` This ensures the FhirEngine instance + is only created when it's accessed for the first time, not immediately when + the app starts. + +1. Add the following convenience method in the `FhirApplication` class for + easier access throughout your application: + + ```kotlin + companion object { + fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine + } + ``` -```kotlin -FhirEngineProvider.init( - FhirEngineConfiguration( - enableEncryptionIfSupported = true, - RECREATE_AT_OPEN, - ServerConfiguration( - baseUrl = "http://10.0.2.2:8080/fhir/", - httpLogger = - HttpLogger( - HttpLogger.Configuration( - if (BuildConfig.DEBUG) HttpLogger.Level.BODY else HttpLogger.Level.BASIC - ) - ) { Log.d("App-HttpLog", it) }, - ), - ) -) -``` + This static method lets you retrieve the FHIR Engine instance from anywhere + in the app using the context. -This initializes FHIR Engine by setting a number of configurations. Pay -attention to the `baseUrl` in `ServerConfiguration`. The IP address `10.0.2.2` -is reserved for the localhost accessible from the Android emulator. Learn -[more](https://developer.android.com/studio/run/emulator-networking). +## Sync data with FHIR server -3. In `FhirApplication` class, add the following property to lazily - instantiate an actual FHIR Engine instance: +1. Create a new class `DownloadWorkManagerImpl.kt`. In this class, you'll + define how the application fetches the next resource from the list to + download.: + ```kotlin + class DownloadWorkManagerImpl : DownloadWorkManager { + private val urls = LinkedList(listOf("Patient")) -```kotlin - private val fhirEngine: FhirEngine by lazy { FhirEngineProvider.getInstance(this) } -``` + override suspend fun getNextRequest(): DownloadRequest? { + val url = urls.poll() ?: return null + return DownloadRequest.of(url) + } -4. Finally, add the following code as a convenience method for the rest of the - codelab: + override suspend fun getSummaryRequestUrls() = mapOf() -```kotlin -companion object { - fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine -} -``` + override suspend fun processResponse(response: Resource): Collection { + var bundleCollection: Collection = mutableListOf() + if (response is Bundle && response.type == Bundle.BundleType.SEARCHSET) { + bundleCollection = response.entry.map { it.resource } + } + return bundleCollection + } + } + ``` -## Sync data with FHIR server + This class has a queue of resource types it wants to download. It processes + responses and extracts the resources from the returned bundle, which get + saved into the local database. -1. Create a new class `DownloadWorkManagerImpl.kt`: +1. Create a new class `AppFhirSyncWorker.kt` This class defines how the app + will sync with the remote FHIR server using a background worker. -```kotlin -class DownloadWorkManagerImpl : DownloadWorkManager { - private val urls = LinkedList(listOf("Patient")) + ```kotlin + class AppFhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : + FhirSyncWorker(appContext, workerParams) { - override suspend fun getNextRequest(): Request? { - val url = urls.poll() ?: return null - return Request.of(url) - } + override fun getDownloadWorkManager() = DownloadWorkManagerImpl() - override suspend fun getSummaryRequestUrls() = mapOf() + override fun getConflictResolver() = AcceptLocalConflictResolver - override suspend fun processResponse(response: Resource): Collection { - var bundleCollection: Collection = mutableListOf() - if (response is Bundle && response.type == Bundle.BundleType.SEARCHSET) { - bundleCollection = response.entry.map { it.resource } + override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) } - return bundleCollection - } -} -``` + ``` -2. Create a new class `FhirSyncWorker.kt` + Here, we've defined which download manager, conflict resolver, and FHIR + engine instance to use for syncing. -```kotlin -class FhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : - FhirSyncWorker(appContext, workerParams) { - - override fun getDownloadWorkManager() = DownloadWorkManagerImpl() - - override fun getConflictResolver() = AcceptLocalConflictResolver +1. In your ViewModel, `PatientListViewModel.kt`, you'll set up a one-time sync + mechanism. Locate and add this code to the `triggerOneTimeSync()` function: - override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) -} -``` - -3. In `PatientListViewModel.kt`, add the following code to the body of - `triggerOneTimeSync()` function - -```kotlin -viewModelScope.launch { - Sync.oneTimeSync(getApplication()) + ```kotlin + viewModelScope.launch { + Sync.oneTimeSync(getApplication()) .shareIn(this, SharingStarted.Eagerly, 10) .collect { _pollState.emit(it) } } -``` + ``` -4. In `PatientListFragment.kt`, add the following code to the body of function - `handleSyncJobStatus` + This coroutine initiates a one-time sync with the FHIR server using the + AppFhirSyncWorker we defined earlier. It will then update the UI based on + the state of the sync process. -```kotlin -when (syncJobStatus) { - is SyncJobStatus.Finished -> { +1. In the `PatientListFragment.kt` file, update the body of the + `handleSyncJobStatus` function: + + ```kotlin + when (syncJobStatus) { + is SyncJobStatus.Finished -> { Toast.makeText(requireContext(), "Sync Finished", Toast.LENGTH_SHORT).show() viewModel.searchPatientsByName("") + } + else -> {} } - else -> {} -} -``` + ``` -Now click the `Sync` button in the menu, and you should see the patients in your -local FHIR server being downloaded to the application. + Here, when the sync process finishes, a toast message will display notifying + the user, and the app will then display all patients by invoking a search + with an empty name. + +Now that everything is set up, run your app. Click the `Sync` button in the +menu. If everything works correctly, you should see the patients from your local +FHIR server being downloaded and displayed in the application. Patient list -## Modify and upload patient data +## Modify and Upload Patient Data -In `PatientListViewModel.kt`, add the following code to `triggerUpdate` function +In this section, we will guide you through the process of modifying patient data +based on specific criteria and uploading the updated data to your FHIR server. +Specifically, we will swap the address cities for patients residing in +`Wakefield` and `Taunton`. -```kotlin +### **Step 1**: Set Up the modification logic in PatientListViewModel - viewModelScope.launch { - val fhirEngine = FhirApplication.fhirEngine(getApplication()) - - val patientsFromWakefield = - fhirEngine.search { - filter( - Patient.ADDRESS_CITY, - { - modifier = StringFilterModifier.CONTAINS - value = "Wakefield" - } - ) - } +The code in this section is added to the `triggerUpdate` function in +`PatientListViewModel` - val patientsFromTaunton = - fhirEngine.search { - filter( - Patient.ADDRESS_CITY, - { - modifier = StringFilterModifier.CONTAINS - value = "Taunton" - } - ) - } +1. **Access the FHIR Engine**: - patientsFromWakefield.forEach { - it.address.first().city = "Taunton" - fhirEngine.update(it) - } + Start by getting a reference to the FHIR engine in the + `PatientListViewModel.kt`. - patientsFromTaunton.forEach { - it.address.first().city = "Wakefield" - fhirEngine.update(it) - } + ```kotlin + viewModelScope.launch { + val fhirEngine = FhirApplication.fhirEngine(getApplication()) + ``` + + This code launches a coroutine within the ViewModel's scope and initializes + the FHIR engine. + +1. **Search for Patients from Wakefield**: + + Use the FHIR engine to search for patients with an address city of + `Wakefield`. + + ```kotlin + val patientsFromWakefield = + fhirEngine.search { + filter( + Patient.ADDRESS_CITY, + { + modifier = StringFilterModifier.MATCHES_EXACTLY + value = "Wakefield" + } + ) + } + ``` + + Here, we are using the FHIR engine's `search` method to filter patients + based on their address city. The result will be a list of patients from + Wakefield. + +1. **Search for Patients from Taunton**: + + Similarly, search for patients with an address city of `Taunton`. + + ```kotlin + val patientsFromTaunton = + fhirEngine.search { + filter( + Patient.ADDRESS_CITY, + { + modifier = StringFilterModifier.MATCHES_EXACTLY + value = "Taunton" + } + ) + } + ``` + + We now have two lists of patients - one from Wakefield and the other from + Taunton. + +1. **Modify Patient Addresses**: + + Go through each patient in the `patientsFromWakefield` list, change their + city to `Taunton`, and update them in the FHIR engine. + + ```kotlin + patientsFromWakefield.forEach { + it.resource.address.first().city = "Taunton" + fhirEngine.update(it.resource) + } + ``` - triggerOneTimeSync() + Similarly, update each patient in the `patientsFromTaunton` list to have + their city changed to `Wakefield`. + + ```kotlin + patientsFromTaunton.forEach { + it.resource.address.first().city = "Wakefield" + fhirEngine.update(it.resource) } -``` + ``` -Now click the `Update` button in the menu, you should see the address city for -patient `Aaron697` and `Abby752` are swapped. +1. **Initiate Synchronization**: -Open the URL `http://localhost:8080/fhir/Patient/` in a browser and verify that -the address city for these patients are updated on the local server. + After modifying the data locally, trigger a one-time sync to ensure the data + is updated on the FHIR server. -## Search for patients by name + ```kotlin + triggerOneTimeSync() + ``` -1. In `PatientListViewModel.kt` change the signature of function - `getSearchResults()` to `getSearchResults(nameQuery: String = "")`. + The closing brace `}` signifies the end of the coroutine launched at the + beginning. -2. Modify the function body and add the following code to the `search` - function call +### **Step 2**: Test the Functionality + +1. **UI Testing**: + + Run your app. Click the `Update` button in the menu. You should see the + address cities for patient `Aaron697` and `Abby752` swapped. + +1. **Server Verification**: + + Open a browser and navigate to `http://localhost:8080/fhir/Patient/`. Verify + that the address city for patients `Aaron697` and `Abby752` is updated on + the local FHIR server. + +By following these steps, you've successfully implemented a mechanism to modify +patient data and synchronize the changes with your FHIR server. + +## Search for Patients by Name + +Searching for patients by their names can provide a user-friendly way of +retrieving information. Here, we'll walk you through the process of implementing +this feature in your application. + +### **Step 1**: Update the Function Signature + +Navigate to your `PatientListViewModel.kt` file and find the function named +`searchPatientsByName`. We will be adding code into this function. + +To filter the results based on the provided name query, incorporate the +following conditional code block: ```kotlin -if (nameQuery.isNotEmpty()) { - filter( - Patient.NAME, - { - modifier = StringFilterModifier.CONTAINS - value = nameQuery +viewModelScope.launch { + val fhirEngine = FhirApplication.fhirEngine(getApplication()) + if (nameQuery.isNotEmpty()) { + fhirEngine.search { + filter( + Patient.NAME, + { + modifier = StringFilterModifier.CONTAINS + value = nameQuery + }, + ) } - ) + } } ``` -3. Add the following code to `searchPatientsByName` +Here, if the `nameQuery` is not empty, the search function will filter the +results to only include patients whose names contain the specified query. -```kotlin -updatePatientList { getSearchResults(nameQuery) } -``` +### **Step 2**: Test the New Search Functionality -Relaunch the app, now you can search for patients by name. +1. **Relaunch the App**: + + After making these changes, rebuild and run your app. + +1. **Search for Patients**: On the patient list screen, use the search + functionality. You should now be able to enter a name (or part of a name) to + filter the list of patients accordingly. + +With these steps completed, you've enhanced your application by providing users +with the ability to efficiently search for patients by their names. This can +significantly improve user experience and efficiency in data retrieval. ## Congratulations! @@ -371,4 +484,3 @@ You have used the FHIR Engine Library to manage FHIR resources in your app: ### Learn More * [FHIR Engine developer documentation](https://github.com/google/android-fhir/wiki/FHIR-Engine-Library) - From c7750e4192bcfbe3f874d73be88b3c39dd3fa8d1 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Sat, 21 Oct 2023 18:55:36 +0100 Subject: [PATCH 17/69] Bump up the knowledge manager version to 0.1.0 alpha03 (#2277) --- buildSrc/src/main/kotlin/Releases.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index 19b6cec415..56663de0cb 100644 --- a/buildSrc/src/main/kotlin/Releases.kt +++ b/buildSrc/src/main/kotlin/Releases.kt @@ -74,7 +74,7 @@ object Releases { object Knowledge : LibraryArtifact { override val artifactId = "knowledge" - override val version = "0.1.0-alpha02" + override val version = "0.1.0-alpha03" override val name = "Android FHIR Knowledge Manager Library" } From b15a259c0a149af41b9f43493549334bc5f65114 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Sun, 22 Oct 2023 11:18:03 +0100 Subject: [PATCH 18/69] Use published libraries in workflow library's dependencies (#2278) --- buildSrc/src/main/kotlin/Dependencies.kt | 4 ++-- datacapture/build.gradle.kts | 2 +- workflow/build.gradle.kts | 11 ++++------- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index bf3b3fd7b7..a6b3d99113 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -228,8 +228,8 @@ object Dependencies { } const val androidFhirCommon = "0.1.0-alpha05" - const val androidFhirEngine = "0.1.0-beta04" - const val androidFhirKnowledge = "0.1.0-alpha01" + const val androidFhirEngine = "0.1.0-beta05" + const val androidFhirKnowledge = "0.1.0-alpha03" const val apacheCommonsCompress = "1.21" const val desugarJdkLibs = "2.0.3" const val caffeine = "2.9.1" diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts index 6e8dbee0a8..78ba2939d9 100644 --- a/datacapture/build.gradle.kts +++ b/datacapture/build.gradle.kts @@ -82,7 +82,6 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) - implementation(Dependencies.androidFhirCommon) implementation(Dependencies.Androidx.appCompat) implementation(Dependencies.Androidx.constraintLayout) implementation(Dependencies.Androidx.coreKtx) @@ -96,6 +95,7 @@ dependencies { implementation(Dependencies.Kotlin.kotlinCoroutinesCore) implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.Lifecycle.viewModelKtx) + implementation(Dependencies.androidFhirCommon) implementation(Dependencies.material) implementation(Dependencies.timber) diff --git a/workflow/build.gradle.kts b/workflow/build.gradle.kts index 5ae54c8cb0..cc26ff7c98 100644 --- a/workflow/build.gradle.kts +++ b/workflow/build.gradle.kts @@ -86,7 +86,6 @@ configurations { } dependencies { - testImplementation(project(mapOf("path" to ":knowledge"))) coreLibraryDesugaring(Dependencies.desugarJdkLibs) androidTestImplementation(Dependencies.AndroidxTest.core) @@ -104,19 +103,16 @@ dependencies { api(Dependencies.HapiFhir.guavaCaching) implementation(Dependencies.Androidx.coreKtx) - implementation(Dependencies.Cql.evaluator) implementation(Dependencies.Cql.evaluatorFhirJackson) - implementation(Dependencies.timber) - implementation(Dependencies.HapiFhir.guavaCaching) - implementation(Dependencies.Kotlin.kotlinCoroutinesAndroid) implementation(Dependencies.Kotlin.kotlinCoroutinesCore) implementation(Dependencies.Kotlin.stdlib) + implementation(Dependencies.androidFhirEngine) { exclude(module = "truth") } + implementation(Dependencies.androidFhirKnowledge) + implementation(Dependencies.timber) implementation(Dependencies.xerces) - implementation(project(":engine")) { exclude(module = "truth") } - implementation(project(":knowledge")) testImplementation(Dependencies.AndroidxTest.core) testImplementation(Dependencies.jsonAssert) @@ -124,6 +120,7 @@ dependencies { testImplementation(Dependencies.robolectric) testImplementation(Dependencies.truth) testImplementation(Dependencies.xmlUnit) + testImplementation(project(mapOf("path" to ":knowledge"))) testImplementation(project(":workflow-testing")) } From 11e7380f70a129bb3f0c2466b6573bbd71273a35 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Sun, 22 Oct 2023 12:59:58 +0100 Subject: [PATCH 19/69] Bump workflow library version to 0.1.0-alpha04 (#2279) --- buildSrc/src/main/kotlin/Releases.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index 56663de0cb..38cb8d700c 100644 --- a/buildSrc/src/main/kotlin/Releases.kt +++ b/buildSrc/src/main/kotlin/Releases.kt @@ -60,7 +60,7 @@ object Releases { object Workflow : LibraryArtifact { override val artifactId = "workflow" - override val version = "0.1.0-alpha03" + override val version = "0.1.0-alpha04" override val name = "Android FHIR Workflow Library" } From 45f8b41f73e0d487a859c3325e14d4b1621f5a6b Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Mon, 23 Oct 2023 20:20:42 +0530 Subject: [PATCH 20/69] Adding PerResourceLocalChangeFetcher (#2257) * adding per resource change fetcher * Reverting fhir application change * spotless corrections * review comments --- .../android/fhir/db/impl/DatabaseImplTest.kt | 17 +++ .../com/google/android/fhir/db/Database.kt | 6 + .../android/fhir/db/impl/DatabaseImpl.kt | 4 + .../fhir/db/impl/dao/LocalChangeDao.kt | 14 +++ .../fhir/sync/upload/LocalChangeFetcher.kt | 21 ++++ .../AllChangesLocalChangeFetcherTest.kt | 4 +- .../PerResourceLocalChangeFetcherTest.kt | 114 ++++++++++++++++++ 7 files changed, 179 insertions(+), 1 deletion(-) rename engine/src/test/java/com/google/android/fhir/sync/upload/{ => fetcher}/AllChangesLocalChangeFetcherTest.kt (94%) create mode 100644 engine/src/test/java/com/google/android/fhir/sync/upload/fetcher/PerResourceLocalChangeFetcherTest.kt 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 7ea0240a23..5a0b2a4869 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 @@ -233,6 +233,23 @@ class DatabaseImplTest { assertThat(database.getLocalChanges(ResourceType.Encounter, patient.logicalId)).isEmpty() } + @Test + fun getAllChangesForEarliestChangedResource_withMultipleChanges_shouldReturnFirstChange() = + runBlocking { + val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") + database.insert(patient) + database.insert(TEST_PATIENT_2) + database.update( + TEST_PATIENT_1.copy().apply { gender = Enumerations.AdministrativeGender.FEMALE }, + ) + assertThat( + database.getAllChangesForEarliestChangedResource().all { + it.resourceId.equals(TEST_PATIENT_1.logicalId) + }, + ) + .isTrue() + } + @Test fun clearDatabase_shouldClearAllTablesData() = runBlocking { val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") diff --git a/engine/src/main/java/com/google/android/fhir/db/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt index cbbc840b64..fac887c229 100644 --- a/engine/src/main/java/com/google/android/fhir/db/Database.kt +++ b/engine/src/main/java/com/google/android/fhir/db/Database.kt @@ -105,6 +105,12 @@ internal interface Database { */ suspend fun getAllLocalChanges(): List + /** + * Retrieves all [LocalChange]s for the [Resource] which has the [LocalChange] with the oldest + * [LocalChange.timestamp] + */ + suspend fun getAllChangesForEarliestChangedResource(): List + /** Retrieves the count of [LocalChange]s stored in the database. */ suspend fun getLocalChangesCount(): Int 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 acb3572325..02aacd796a 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 @@ -228,6 +228,10 @@ internal class DatabaseImpl( return db.withTransaction { localChangeDao.getLocalChangesCount() } } + override suspend fun getAllChangesForEarliestChangedResource(): List { + return localChangeDao.getAllChangesForEarliestChangedResource().map { it.toLocalChange() } + } + override suspend fun deleteUpdates(token: LocalChangeToken) { db.withTransaction { localChangeDao.discardLocalChanges(token) } } 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 ecd24b4ece..24985ed9ed 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 @@ -206,6 +206,20 @@ internal abstract class LocalChangeDao { resourceId: String, ): List + @Query( + """ + SELECT * + FROM LocalChangeEntity + WHERE resourceUuid = ( + SELECT resourceUuid + FROM LocalChangeEntity + ORDER BY timestamp ASC + LIMIT 1) + ORDER BY timestamp ASC + """, + ) + abstract suspend fun getAllChangesForEarliestChangedResource(): List + class InvalidLocalChangeException(message: String?) : Exception(message) } diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt index 8b337963e7..98beb2c9c0 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt @@ -71,6 +71,25 @@ internal class AllChangesLocalChangeFetcher( SyncUploadProgress(database.getLocalChangesCount(), total) } +internal class PerResourceLocalChangeFetcher( + private val database: Database, +) : LocalChangeFetcher { + + override var total by Delegates.notNull() + + suspend fun initTotalCount() { + total = database.getLocalChangesCount() + } + + override suspend fun hasNext(): Boolean = database.getLocalChangesCount().isNotZero() + + override suspend fun next(): List = + database.getAllChangesForEarliestChangedResource() + + override suspend fun getProgress(): SyncUploadProgress = + SyncUploadProgress(database.getLocalChangesCount(), total) +} + /** Represents the mode in which local changes should be fetched. */ sealed class LocalChangesFetchMode { object AllChanges : LocalChangesFetchMode() @@ -88,6 +107,8 @@ internal object LocalChangeFetcherFactory { when (mode) { is LocalChangesFetchMode.AllChanges -> AllChangesLocalChangeFetcher(database).apply { initTotalCount() } + is LocalChangesFetchMode.PerResource -> + PerResourceLocalChangeFetcher(database).apply { initTotalCount() } else -> throw NotImplementedError("$mode is not implemented yet.") } } diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/fetcher/AllChangesLocalChangeFetcherTest.kt similarity index 94% rename from engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt rename to engine/src/test/java/com/google/android/fhir/sync/upload/fetcher/AllChangesLocalChangeFetcherTest.kt index 89a334cf20..3c0255eb5a 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/fetcher/AllChangesLocalChangeFetcherTest.kt @@ -14,10 +14,12 @@ * limitations under the License. */ -package com.google.android.fhir.sync.upload +package com.google.android.fhir.sync.upload.fetcher import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.FhirServices +import com.google.android.fhir.sync.upload.AllChangesLocalChangeFetcher +import com.google.android.fhir.sync.upload.SyncUploadProgress import com.google.common.truth.Truth.assertThat import java.util.Date import kotlinx.coroutines.test.runTest diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/fetcher/PerResourceLocalChangeFetcherTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/fetcher/PerResourceLocalChangeFetcherTest.kt new file mode 100644 index 0000000000..17627de13e --- /dev/null +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/fetcher/PerResourceLocalChangeFetcherTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2023 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.sync.upload.fetcher + +import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.FhirServices +import com.google.android.fhir.LocalChange +import com.google.android.fhir.logicalId +import com.google.android.fhir.sync.upload.PerResourceLocalChangeFetcher +import com.google.common.truth.Truth.assertThat +import java.util.Date +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Meta +import org.hl7.fhir.r4.model.Patient +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PerResourceLocalChangeFetcherTest { + + private val services = + FhirServices.builder(ApplicationProvider.getApplicationContext()).inMemory().build() + private val database = services.database + + @Test + fun `fetcher is created correctly`() = runTest { + database.insert(TEST_PATIENT_1, TEST_PATIENT_2) + database.update( + TEST_PATIENT_1.copy().apply { gender = Enumerations.AdministrativeGender.FEMALE }, + ) + val fetcher = PerResourceLocalChangeFetcher(database).apply { initTotalCount() } + + assertThat(fetcher.getProgress().initialTotal).isEqualTo(3) + } + + @Test + fun `hasNext returns correct value`() = runTest { + database.insert(TEST_PATIENT_1, TEST_PATIENT_2) + database.update( + TEST_PATIENT_1.copy().apply { gender = Enumerations.AdministrativeGender.FEMALE }, + ) + val fetcher = PerResourceLocalChangeFetcher(database).apply { initTotalCount() } + + assertThat(fetcher.hasNext()).isTrue() + database.deleteUpdates(listOf(TEST_PATIENT_1)) + assertThat(fetcher.hasNext()).isTrue() + database.deleteUpdates(listOf(TEST_PATIENT_2)) + assertThat(fetcher.hasNext()).isFalse() + } + + @Test + fun `next returns correct set of changes in the right order`() = runTest { + database.insert(TEST_PATIENT_1, TEST_PATIENT_2) + database.update( + TEST_PATIENT_1.copy().apply { gender = Enumerations.AdministrativeGender.FEMALE }, + ) + val fetcher = PerResourceLocalChangeFetcher(database).apply { initTotalCount() } + + val firstSetOfChanges = fetcher.next() + database.deleteUpdates(listOf(TEST_PATIENT_1)) + val secondSetOfChanges = fetcher.next() + database.deleteUpdates(listOf(TEST_PATIENT_2)) + + assertThat(firstSetOfChanges.size).isEqualTo(2) + with(firstSetOfChanges[0]) { + assertThat(type).isEqualTo(LocalChange.Type.INSERT) + assertThat(resourceId).isEqualTo(TEST_PATIENT_1.logicalId) + } + + with(firstSetOfChanges[1]) { + assertThat(type).isEqualTo(LocalChange.Type.UPDATE) + assertThat(resourceId).isEqualTo(TEST_PATIENT_1.logicalId) + } + + assertThat(secondSetOfChanges.size).isEqualTo(1) + with(secondSetOfChanges[0]) { + assertThat(type).isEqualTo(LocalChange.Type.INSERT) + assertThat(resourceId).isEqualTo(TEST_PATIENT_2.logicalId) + } + } + + companion object { + private const val TEST_PATIENT_1_ID = "test_patient_1" + private var TEST_PATIENT_1 = + Patient().apply { + id = TEST_PATIENT_1_ID + gender = Enumerations.AdministrativeGender.MALE + } + + private const val TEST_PATIENT_2_ID = "test_patient_2" + private var TEST_PATIENT_2 = + Patient().apply { + id = TEST_PATIENT_2_ID + gender = Enumerations.AdministrativeGender.MALE + meta = Meta().apply { lastUpdated = Date() } + } + } +} From 600b013ab9aa01a5d19c7f3ad7eca1cb9eda1d6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Oct 2023 17:34:00 +0100 Subject: [PATCH 21/69] Bump com.google.guava:guava from 32.1.2-android to 32.1.3-android (#2284) Bumps [com.google.guava:guava](https://github.com/google/guava) from 32.1.2-android to 32.1.3-android. - [Release notes](https://github.com/google/guava/releases) - [Commits](https://github.com/google/guava/commits) --- updated-dependencies: - dependency-name: com.google.guava:guava dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- common/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 0d2c90c049..df5bba1412 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -23,7 +23,7 @@ configurations { exclude(module = "hapi-fhir-caching-caffeine") exclude(group = "com.github.ben-manes.caffeine", module = "caffeine") - resolutionStrategy { force("com.google.guava:guava:32.1.2-android") } + resolutionStrategy { force("com.google.guava:guava:32.1.3-android") } } } From de3a0fdb8e12f506fc8e70fa0f23038bb4ddf75f Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Tue, 24 Oct 2023 15:05:34 +0100 Subject: [PATCH 22/69] update codelab (#2289) * update codelab * make simpler --- codelabs/engine/README.md | 51 ++-- .../fhir/codelabs/engine/FhirApplication.kt | 32 +-- .../engine/PatientItemRecyclerViewAdapter.kt | 14 +- .../codelabs/engine/PatientItemViewHolder.kt | 11 +- .../codelabs/engine/PatientListFragment.kt | 4 +- .../codelabs/engine/PatientListViewModel.kt | 23 +- .../engine/gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 61608 bytes codelabs/engine/gradlew | 269 +++++++++++------- codelabs/engine/gradlew.bat | 15 +- 9 files changed, 232 insertions(+), 187 deletions(-) diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md index 0c5d5dd160..0b377c9422 100644 --- a/codelabs/engine/README.md +++ b/codelabs/engine/README.md @@ -125,7 +125,7 @@ file of your project: dependencies { // ... - implementation("com.google.android.fhir:engine:0.1.0-beta05") + implementation("com.google.android.fhir:engine:0.1.0-beta04") } ``` @@ -185,23 +185,29 @@ outlined below will guide you through the process. supports it. * `RECREATE_AT_OPEN`: Determines the database error strategy. In this case, it recreates the database if an error occurs upon opening. - * `baseUrl` in ServerConfiguration: This is the FHIR server's base URL. The - provided IP address `10.0.2.2` is specially reserved for localhost, + * `baseUrl` in `ServerConfiguration`: This is the FHIR server's base URL. + The provided IP address `10.0.2.2` is specially reserved for localhost, accessible from the Android emulator. Learn [more](https://developer.android.com/studio/run/emulator-networking). 1. In the `FhirApplication` class, add the following line to lazily instantiate - the FHIR Engine: `kotlin private val fhirEngine: FhirEngine by lazy { - FhirEngineProvider.getInstance(this) }` This ensures the FhirEngine instance + the FHIR Engine: + ```kotlin + private val fhirEngine: FhirEngine by + lazy { FhirEngineProvider.getInstance(this) } + ``` + + This ensures the FhirEngine instance is only created when it's accessed for the first time, not immediately when the app starts. -1. Add the following convenience method in the `FhirApplication` class for +1. Add the following convenience methods in the `FhirApplication` class for easier access throughout your application: ```kotlin companion object { - fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine + fun fhirEngine(context: Context) = + (context.applicationContext as FhirApplication).fhirEngine } ``` @@ -257,7 +263,7 @@ outlined below will guide you through the process. engine instance to use for syncing. 1. In your ViewModel, `PatientListViewModel.kt`, you'll set up a one-time sync - mechanism. Locate and add this code to the `triggerOneTimeSync()` function: + mechanism. locate and add this code to the `triggerOneTimeSync()` function: ```kotlin viewModelScope.launch { @@ -301,7 +307,7 @@ based on specific criteria and uploading the updated data to your FHIR server. Specifically, we will swap the address cities for patients residing in `Wakefield` and `Taunton`. -### **Step 1**: Set Up the modification logic in PatientListViewModel +### **Step 1**: Set Up the Modification Logic in PatientListViewModel The code in this section is added to the `triggerUpdate` function in `PatientListViewModel` @@ -390,6 +396,7 @@ The code in this section is added to the `triggerUpdate` function in ```kotlin triggerOneTimeSync() + } ``` The closing brace `}` signifies the end of the coroutine launched at the @@ -426,20 +433,20 @@ To filter the results based on the provided name query, incorporate the following conditional code block: ```kotlin -viewModelScope.launch { - val fhirEngine = FhirApplication.fhirEngine(getApplication()) - if (nameQuery.isNotEmpty()) { - fhirEngine.search { - filter( - Patient.NAME, - { - modifier = StringFilterModifier.CONTAINS - value = nameQuery - }, - ) + viewModelScope.launch { + val fhirEngine = FhirApplication.fhirEngine(getApplication()) + if (nameQuery.isNotEmpty()) { + fhirEngine.search { + filter( + Patient.NAME, + { + modifier = StringFilterModifier.CONTAINS + value = nameQuery + }, + ) + } + } } - } -} ``` Here, if the `nameQuery` is not empty, the search function will filter the diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/FhirApplication.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/FhirApplication.kt index 658d84ca14..f734b4f450 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/FhirApplication.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/FhirApplication.kt @@ -17,41 +17,15 @@ package com.google.android.fhir.codelabs.engine import android.app.Application -import android.content.Context -import android.util.Log -import com.google.android.fhir.DatabaseErrorStrategy.RECREATE_AT_OPEN -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.FhirEngineConfiguration -import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.ServerConfiguration -import com.google.android.fhir.sync.remote.HttpLogger class FhirApplication : Application() { - private val fhirEngine: FhirEngine by lazy { FhirEngineProvider.getInstance(this) } + // Add code to create companion object to access fhirEngine override fun onCreate() { super.onCreate() - FhirEngineProvider.init( - FhirEngineConfiguration( - enableEncryptionIfSupported = true, - RECREATE_AT_OPEN, - ServerConfiguration( - baseUrl = "http://10.0.2.2:8080/fhir/", - httpLogger = - HttpLogger( - HttpLogger.Configuration( - if (BuildConfig.DEBUG) HttpLogger.Level.BODY else HttpLogger.Level.BASIC, - ), - ) { - Log.d("App-HttpLog", it) - }, - ), - ), - ) + // Add code to initialise the FHIR Engine } - companion object { - fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine - } + // Add code to create companion object to access fhirEngine } diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt index 9e0d322c46..a5cf3f448b 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt @@ -20,21 +20,19 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import com.google.android.fhir.SearchResult import com.google.android.fhir.codelabs.engine.databinding.PatientListItemViewBinding import org.hl7.fhir.r4.model.Patient class PatientItemRecyclerViewAdapter : - ListAdapter, PatientItemViewHolder>(PatientItemDiffCallback()) { + ListAdapter(PatientItemDiffCallback()) { - class PatientItemDiffCallback : DiffUtil.ItemCallback>() { - override fun areItemsTheSame(oldItem: SearchResult, newItem: SearchResult) = - oldItem.equals(newItem) + class PatientItemDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Patient, newItem: Patient) = oldItem.id == newItem.id override fun areContentsTheSame( - oldItem: SearchResult, - newItem: SearchResult, - ) = oldItem.equals(newItem) + oldItem: Patient, + newItem: Patient, + ) = oldItem.equalsDeep(newItem) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PatientItemViewHolder { 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 f37eddfb5b..409441019f 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 @@ -18,7 +18,6 @@ package com.google.android.fhir.codelabs.engine import android.widget.TextView import androidx.recyclerview.widget.RecyclerView -import com.google.android.fhir.SearchResult import com.google.android.fhir.codelabs.engine.databinding.PatientListItemViewBinding import org.hl7.fhir.r4.model.Patient @@ -29,12 +28,10 @@ class PatientItemViewHolder(binding: PatientListItemViewBinding) : private val genderTextView: TextView = binding.gender private val cityTextView = binding.city - fun bind(patientItem: SearchResult) { + fun bind(patientItem: Patient) { nameTextView.text = - patientItem.resource.name.first().let { - it.given.joinToString(separator = " ") + " " + it.family - } - genderTextView.text = patientItem.resource.gender.display - cityTextView.text = patientItem.resource.address.singleOrNull()?.city + patientItem.name.first().let { it.given.joinToString(separator = " ") + " " + it.family } + genderTextView.text = patientItem.gender.display + cityTextView.text = patientItem.address.singleOrNull()?.city } } diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt index e81cc5416c..8c260fabf0 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt @@ -75,7 +75,9 @@ class PatientListFragment : Fragment() { } } - private fun handleSyncJobStatus(syncJobStatus: SyncJobStatus) {} + private fun handleSyncJobStatus(syncJobStatus: SyncJobStatus) { + // Add code to display Toast when sync job is complete + } private fun initMenu() { (requireActivity() as MenuHost).addMenuProvider( diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt index 673623e3cb..b25123a148 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.android.fhir.SearchResult import com.google.android.fhir.search.Order import com.google.android.fhir.search.search import com.google.android.fhir.sync.SyncJobStatus @@ -36,21 +35,27 @@ class PatientListViewModel(application: Application) : AndroidViewModel(applicat val pollState: Flow get() = _pollState - val liveSearchedPatients = MutableLiveData>>() + val liveSearchedPatients = MutableLiveData>() init { updatePatientList { getSearchResults() } } - fun triggerOneTimeSync() {} + fun triggerOneTimeSync() { + // Add code to start sync + } /* Fetches patients stored locally based on the city they are in, and then updates the city field for each patient. Once that is complete, trigger a new sync so the changes can be uploaded. */ - fun triggerUpdate() {} + fun triggerUpdate() { + // Add code to trigger update + } - fun searchPatientsByName(nameQuery: String) {} + fun searchPatientsByName(nameQuery: String) { + // Add code to use fhirEngine to search for patients + } /** * [updatePatientList] calls the search and count lambda and updates the live data values @@ -58,16 +63,16 @@ class PatientListViewModel(application: Application) : AndroidViewModel(applicat * client every time search query changes or data-sync is completed. */ private fun updatePatientList( - search: suspend () -> List>, + search: suspend () -> List, ) { viewModelScope.launch { liveSearchedPatients.value = search() } } - private suspend fun getSearchResults(): List> { - val patients: MutableList> = mutableListOf() + private suspend fun getSearchResults(): List { + val patients: MutableList = mutableListOf() FhirApplication.fhirEngine(this.getApplication()) .search { sort(Patient.GIVEN, Order.ASCENDING) } - .let { patients.addAll(it) } + .let { patients.addAll(it.map { it.resource }) } return patients } } diff --git a/codelabs/engine/gradle/wrapper/gradle-wrapper.jar b/codelabs/engine/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..ccebba7710deaf9f98673a68957ea02138b60d0a 100644 GIT binary patch delta 42019 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 delta 39521 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 R149saZvb8 \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/codelabs/engine/gradlew.bat b/codelabs/engine/gradlew.bat index ac1b06f938..6689b85bee 100644 --- a/codelabs/engine/gradlew.bat +++ b/codelabs/engine/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From b686143d41f83c5a0ed0644df75545c8359e536e Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:14:09 +0100 Subject: [PATCH 23/69] Update Codelab (#2290) * update codelab * make simpler * update readme --- codelabs/engine/README.md | 79 ++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md index 0b377c9422..31ab482473 100644 --- a/codelabs/engine/README.md +++ b/codelabs/engine/README.md @@ -193,7 +193,7 @@ outlined below will guide you through the process. 1. In the `FhirApplication` class, add the following line to lazily instantiate the FHIR Engine: ```kotlin - private val fhirEngine: FhirEngine by + private val fhirEngine: FhirEngine by lazy { FhirEngineProvider.getInstance(this) } ``` @@ -201,7 +201,7 @@ outlined below will guide you through the process. is only created when it's accessed for the first time, not immediately when the app starts. -1. Add the following convenience methods in the `FhirApplication` class for +1. Add the following convenience method in the `FhirApplication` class for easier access throughout your application: ```kotlin @@ -220,24 +220,24 @@ outlined below will guide you through the process. define how the application fetches the next resource from the list to download.: ```kotlin - class DownloadWorkManagerImpl : DownloadWorkManager { - private val urls = LinkedList(listOf("Patient")) + class DownloadWorkManagerImpl : DownloadWorkManager { + private val urls = LinkedList(listOf("Patient")) - override suspend fun getNextRequest(): DownloadRequest? { - val url = urls.poll() ?: return null - return DownloadRequest.of(url) - } + override suspend fun getNextRequest(): DownloadRequest? { + val url = urls.poll() ?: return null + return DownloadRequest.of(url) + } - override suspend fun getSummaryRequestUrls() = mapOf() + override suspend fun getSummaryRequestUrls() = mapOf() - override suspend fun processResponse(response: Resource): Collection { - var bundleCollection: Collection = mutableListOf() - if (response is Bundle && response.type == Bundle.BundleType.SEARCHSET) { - bundleCollection = response.entry.map { it.resource } + override suspend fun processResponse(response: Resource): Collection { + var bundleCollection: Collection = mutableListOf() + if (response is Bundle && response.type == Bundle.BundleType.SEARCHSET) { + bundleCollection = response.entry.map { it.resource } + } + return bundleCollection } - return bundleCollection } - } ``` This class has a queue of resource types it wants to download. It processes @@ -263,14 +263,14 @@ outlined below will guide you through the process. engine instance to use for syncing. 1. In your ViewModel, `PatientListViewModel.kt`, you'll set up a one-time sync - mechanism. locate and add this code to the `triggerOneTimeSync()` function: + mechanism. Locate and add this code to the `triggerOneTimeSync()` function: ```kotlin viewModelScope.launch { - Sync.oneTimeSync(getApplication()) - .shareIn(this, SharingStarted.Eagerly, 10) - .collect { _pollState.emit(it) } - } + Sync.oneTimeSync(getApplication()) + .shareIn(this, SharingStarted.Eagerly, 10) + .collect { _pollState.emit(it) } + } ``` This coroutine initiates a one-time sync with the FHIR server using the @@ -282,11 +282,11 @@ outlined below will guide you through the process. ```kotlin when (syncJobStatus) { - is SyncJobStatus.Finished -> { - Toast.makeText(requireContext(), "Sync Finished", Toast.LENGTH_SHORT).show() - viewModel.searchPatientsByName("") - } - else -> {} + is SyncJobStatus.Finished -> { + Toast.makeText(requireContext(), "Sync Finished", Toast.LENGTH_SHORT).show() + viewModel.searchPatientsByName("") + } + else -> {} } ``` @@ -429,24 +429,25 @@ this feature in your application. Navigate to your `PatientListViewModel.kt` file and find the function named `searchPatientsByName`. We will be adding code into this function. -To filter the results based on the provided name query, incorporate the -following conditional code block: +To filter the results based on the provided name query, and emit the results for +the UI to update, incorporate the following conditional code block: ```kotlin viewModelScope.launch { - val fhirEngine = FhirApplication.fhirEngine(getApplication()) - if (nameQuery.isNotEmpty()) { - fhirEngine.search { - filter( - Patient.NAME, - { - modifier = StringFilterModifier.CONTAINS - value = nameQuery - }, - ) - } - } + val fhirEngine = FhirApplication.fhirEngine(getApplication()) + if (nameQuery.isNotEmpty()) { + val searchResult = fhirEngine.search { + filter( + Patient.NAME, + { + modifier = StringFilterModifier.CONTAINS + value = nameQuery + }, + ) } + liveSearchedPatients.value = searchResult.map { it.resource } + } +} ``` Here, if the `nameQuery` is not empty, the search function will filter the From 5fb87eafd0e5a797db60bb2043809b12911b1771 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 25 Oct 2023 09:37:12 -0400 Subject: [PATCH 24/69] Removes jcl-over-slf4j due to the conflict between Jakarta Commons Logging and Android's own implementation of those classes. (#2288) --- engine/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 155f481ddc..1473e9e573 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -87,6 +87,7 @@ configurations { exclude(module = "jakarta.activation-api") exclude(module = "javax.activation") exclude(module = "jakarta.xml.bind-api") + exclude(module = "jcl-over-slf4j") forceGuava() forceHapiVersion() From 0141ae25f3190ec0877b7a59917b2b1fe9755347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 09:48:59 +0200 Subject: [PATCH 25/69] =?UTF-8?q?Reduce=20GitHub=20Action=20VM=20Size=20to?= =?UTF-8?q?=208-cores=20=C2=B7=2032=20GB=20RAM=20=C2=B7=20300=20GB=20SSD?= =?UTF-8?q?=20(fixes=20#2294)=20(#2296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 2 +- .github/workflows/codeql.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 29fbc70ecd..9ffbca252b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: # Build will compile APK, test APK and run tests, lint, etc. build: - runs-on: ubuntu-22.04-64core + runs-on: ubuntu-22.04-8core # Steps represent a sequence of tasks that will be executed as part of the job steps: diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index ffbc472edd..25e4197f6b 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -12,7 +12,7 @@ on: jobs: analyze: name: Analyze - runs-on: ubuntu-22.04-64core + runs-on: ubuntu-22.04-8core timeout-minutes: 60 permissions: actions: read From 927c59107b0f48804bae49e9ea5a1288d6cdd0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 12:52:12 +0200 Subject: [PATCH 26/69] Merge CodeQL & Build GitHub Actions (re. #2294) (#2307) This is actually MORE (not less) efficient, because those 2 actions (partially) did the same thing anyway, so there really is no benefit to separately run them in parallel, other than increased resource usage, and more complexity to understand this project's CI. --- .github/workflows/build.yml | 44 +++++++++++++++++++++++++----- .github/workflows/codeql.yaml | 51 ----------------------------------- 2 files changed, 37 insertions(+), 58 deletions(-) delete mode 100644 .github/workflows/codeql.yaml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ffbca252b..a9b39f298c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -name: "GitHub Actions: Build" +name: "Build" # Controls when the action will run. Triggers the workflow on push or pull request # events for the `master` branch @@ -22,18 +22,34 @@ on: tags: build* pull_request: + # The branches below must be a subset of the branches above branches: [ master ] + schedule: + # Run once a week (even if no new code or PRs) to detect random regressions + - cron: '32 13 * * 2' + env: # Allow precise monitoring of the save/restore of Gradle User Home by `gradle-build-action` # See https://github.com/marketplace/actions/gradle-build-action?version=v2.1.1#cache-debugging-and-analysis GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX: "fhir" # change this to invalidate cache -# A workflow run is made up of one or more jobs that can run sequentially or in parallel + + # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # Build will compile APK, test APK and run tests, lint, etc. build: - runs-on: ubuntu-22.04-8core + timeout-minutes: 60 + permissions: + actions: read + contents: read + # Required by codeql-action + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java' ] # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -49,17 +65,31 @@ jobs: # Fetch origin/master for spotless ratchet to work # https://github.com/diffplug/spotless/issues/1242 fetch-depth: 0 + - name: Setup machine uses: ./.github/actions/commonSetup + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + - name: Build (minimal, for CodeQL) with Gradle + run: ./gradlew --scan --full-stacktrace compileDebugAndroidTestSources + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" + - name: Spotless check - run: ./gradlew spotlessCheck --scan --stacktrace + run: ./gradlew spotlessCheck --scan --full-stacktrace - - name: Build with Gradle - run: ./gradlew build --scan --stacktrace + - name: Build (full) with Gradle + run: ./gradlew build --scan --full-stacktrace - name: Check with Gradle - run: ./gradlew check --scan --stacktrace + run: ./gradlew check --scan --full-stacktrace - name: Release artifacts to local repo run: ./gradlew publishReleasePublicationToCIRepository --scan diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml deleted file mode 100644 index 25e4197f6b..0000000000 --- a/.github/workflows/codeql.yaml +++ /dev/null @@ -1,51 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [ "master" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "master" ] - schedule: - - cron: '32 13 * * 2' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-22.04-8core - timeout-minutes: 60 - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'java' ] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Switch to Java 17 from Eclipse Temurin distro - uses: actions/setup-java@v3 - with: - java-version: 17 - distribution: temurin - - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - - # TODO: use Autobuild instead of ./gradlew after https://github.com/github/codeql-action/issues/1417 is fixed - # - name: Autobuild - # uses: github/codeql-action/autobuild@v2 - - name: Build with Gradle - run: ./gradlew --scan --full-stacktrace -Dorg.gradle.dependency.verification=off compileDebugAndroidTestSources - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" From a1c4f9fc785a011bf50087e539b8a93703c95920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 13:55:27 +0200 Subject: [PATCH 27/69] Remove unnecessary chmod from GitHub Action (#2303) --- .github/actions/commonSetup/action.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/actions/commonSetup/action.yml b/.github/actions/commonSetup/action.yml index 621d1f2081..c15b0e993b 100644 --- a/.github/actions/commonSetup/action.yml +++ b/.github/actions/commonSetup/action.yml @@ -8,9 +8,6 @@ runs: with: distribution: temurin java-version: "17" - - name: Make files executable - shell: bash - run: chmod +x ./gradlew - name: "Setup Gradle" uses: gradle/gradle-build-action@v2 From 360c6a51ca7bacda339e6c49901747d96b671541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 14:45:54 +0200 Subject: [PATCH 28/69] Revert "Merge CodeQL & Build GitHub Actions (re. #2294) (#2307)" (#2312) This reverts commit 927c59107b0f48804bae49e9ea5a1288d6cdd0ca. Due to https://github.com/google/android-fhir/issues/2310. --- .github/workflows/build.yml | 44 +++++------------------------- .github/workflows/codeql.yaml | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/codeql.yaml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a9b39f298c..9ffbca252b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -name: "Build" +name: "GitHub Actions: Build" # Controls when the action will run. Triggers the workflow on push or pull request # events for the `master` branch @@ -22,34 +22,18 @@ on: tags: build* pull_request: - # The branches below must be a subset of the branches above branches: [ master ] - schedule: - # Run once a week (even if no new code or PRs) to detect random regressions - - cron: '32 13 * * 2' - env: # Allow precise monitoring of the save/restore of Gradle User Home by `gradle-build-action` # See https://github.com/marketplace/actions/gradle-build-action?version=v2.1.1#cache-debugging-and-analysis GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX: "fhir" # change this to invalidate cache - - # A workflow run is made up of one or more jobs that can run sequentially or in parallel +# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # Build will compile APK, test APK and run tests, lint, etc. build: - runs-on: ubuntu-22.04-8core - timeout-minutes: 60 - permissions: - actions: read - contents: read - # Required by codeql-action - security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'java' ] + runs-on: ubuntu-22.04-8core # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -65,31 +49,17 @@ jobs: # Fetch origin/master for spotless ratchet to work # https://github.com/diffplug/spotless/issues/1242 fetch-depth: 0 - - name: Setup machine uses: ./.github/actions/commonSetup - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - - - name: Build (minimal, for CodeQL) with Gradle - run: ./gradlew --scan --full-stacktrace compileDebugAndroidTestSources - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" - - name: Spotless check - run: ./gradlew spotlessCheck --scan --full-stacktrace + run: ./gradlew spotlessCheck --scan --stacktrace - - name: Build (full) with Gradle - run: ./gradlew build --scan --full-stacktrace + - name: Build with Gradle + run: ./gradlew build --scan --stacktrace - name: Check with Gradle - run: ./gradlew check --scan --full-stacktrace + run: ./gradlew check --scan --stacktrace - name: Release artifacts to local repo run: ./gradlew publishReleasePublicationToCIRepository --scan diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000000..25e4197f6b --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,51 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "master" ] + schedule: + - cron: '32 13 * * 2' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-22.04-8core + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Switch to Java 17 from Eclipse Temurin distro + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: temurin + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + # TODO: use Autobuild instead of ./gradlew after https://github.com/github/codeql-action/issues/1417 is fixed + # - name: Autobuild + # uses: github/codeql-action/autobuild@v2 + - name: Build with Gradle + run: ./gradlew --scan --full-stacktrace -Dorg.gradle.dependency.verification=off compileDebugAndroidTestSources + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From dbde40f5fa15eb371428798246197e99fbe60295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 15:32:00 +0200 Subject: [PATCH 29/69] Permit BSD 2 & 3 Licenses (#2298) see https://github.com/google/android-fhir/pull/2282 --- buildSrc/src/main/kotlin/LicenseeConfig.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/buildSrc/src/main/kotlin/LicenseeConfig.kt b/buildSrc/src/main/kotlin/LicenseeConfig.kt index ff66011e62..f17f44ca1c 100644 --- a/buildSrc/src/main/kotlin/LicenseeConfig.kt +++ b/buildSrc/src/main/kotlin/LicenseeConfig.kt @@ -22,6 +22,8 @@ fun Project.configureLicensee() { apply(plugin = "app.cash.licensee") configure { allow("Apache-2.0") + allow("BSD-2-Clause") + allow("BSD-3-Clause") allow("MIT") ignoreDependencies("com.ibm.icu", "icu4j") { From 0e91a64be939759586ff06d695815a47955a4658 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Thu, 26 Oct 2023 21:01:03 +0530 Subject: [PATCH 30/69] Resource consolidation for resources created with POST (#2217) * WIP * test cases * Adding resource UUID in LocalChangeEntity * fixing migration tests * returning resource UUID for insert resource * Clean up SyncJobStatus and add FhirSynchronizer test (#2184) * Clean up SyncJobStatus and add FhirSynchronizer test * remove changes in MAVM * refactor more * Consolidator done * review comments * adding more tests * fix failing test cases * Introducing LocalChangeReference * Adding LocalChangeDaoTest * WIP * added migration and tests for migration * updating kdoc * adding database migration * removing unused refs * json utils test * review comments * addressing review comments * resolving failing test case --------- Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- engine/build.gradle.kts | 1 + .../8.json | 1025 +++++++++++++++++ .../android/fhir/db/impl/DatabaseImplTest.kt | 226 +++- .../db/impl/EncryptedDatabaseErrorTest.kt | 11 + .../db/impl/ResourceDatabaseMigrationTest.kt | 76 ++ .../fhir/db/impl/dao/LocalChangeDaoTest.kt | 359 ++++++ .../com/google/android/fhir/FhirServices.kt | 3 + .../com/google/android/fhir/db/Database.kt | 25 + .../fhir/db/ResourceNotFoundException.kt | 18 +- .../android/fhir/db/impl/DatabaseImpl.kt | 95 +- .../google/android/fhir/db/impl/JsonUtils.kt | 130 +++ .../android/fhir/db/impl/ResourceDatabase.kt | 61 +- .../fhir/db/impl/dao/LocalChangeDao.kt | 315 ++++- .../android/fhir/db/impl/dao/ResourceDao.kt | 31 +- .../LocalChangeResourceReferenceEntity.kt | 48 + .../fhir/sync/upload/ResourceConsolidator.kt | 23 +- .../android/fhir/sync/upload/Uploader.kt | 6 +- .../com/google/android/fhir/JsonUtilsTest.kt | 263 +++++ .../android/fhir/impl/FhirEngineImplTest.kt | 2 +- .../android/fhir/sync/FhirSynchronizerTest.kt | 4 +- .../android/fhir/sync/upload/UploaderTest.kt | 2 +- 21 files changed, 2684 insertions(+), 40 deletions(-) create mode 100644 engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json create mode 100644 engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt create mode 100644 engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt create mode 100644 engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt create mode 100644 engine/src/test/java/com/google/android/fhir/JsonUtilsTest.kt diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 1473e9e573..3ae1cfb19d 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { testImplementation(Dependencies.AndroidxTest.workTestingRuntimeKtx) testImplementation(Dependencies.Kotlin.kotlinCoroutinesTest) testImplementation(Dependencies.junit) + testImplementation(Dependencies.jsonAssert) testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.mockitoKotlin) testImplementation(Dependencies.mockWebServer) diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json new file mode 100644 index 0000000000..a4a50d1caf --- /dev/null +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json @@ -0,0 +1,1025 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "dee7984bc7a1af3c4358443b0d6bbc95", + "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_system_index_value_resourceUuid", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_system", + "index_value", + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TokenIndexEntity_resourceType_index_name_index_system_index_value_resourceUuid` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_system`, `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, 'dee7984bc7a1af3c4358443b0d6bbc95')" + ] + } +} \ 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 5a0b2a4869..86b9ba313d 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 @@ -80,6 +80,7 @@ import org.hl7.fhir.r4.model.RiskAssessment import org.hl7.fhir.r4.model.SearchParameter import org.hl7.fhir.r4.model.StringType import org.json.JSONArray +import org.json.JSONObject import org.junit.After import org.junit.Assert.assertThrows import org.junit.Before @@ -552,7 +553,7 @@ class DatabaseImplTest { .first { it.resourceId == "remote-patient-3" } .let { UploadSyncResult.Success( - it.token, + listOf(it), listOf( Patient().apply { id = it.resourceId @@ -3549,6 +3550,229 @@ class DatabaseImplTest { ) } + @Test + fun updateResourceAndReferences_shouldUpdateResourceEntityResourceId() = runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + // Retrieving ResourceEntity so that we have the resourceUuid available for assertions + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + // perform updates + database.updateResourceAndReferences( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new server assigned ID + val updatedPatientResourceEntity = + database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) + assertThat(updatedPatientResourceEntity.resourceUuid) + .isEqualTo(patientResourceEntity.resourceUuid) + } + + @Test + fun updateResourceAndReferences_shouldUpdateLocalChangeResourceId() = runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + // perform updates + database.updateResourceAndReferences( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new server assigned ID + val patientLocalChanges = database.getLocalChanges(patientResourceEntity.resourceUuid) + assertThat(patientLocalChanges.all { it.resourceId == remotelyCreatedPatientResourceId }) + .isTrue() + } + + @Test + fun updateResourceAndReferences_shouldUpdateReferencesInReferringLocalChangesOfInsertType() = + runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + + // create an observation for the patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + addPerformer(Reference("Practitioner/123")) + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + // perform updates + database.updateResourceAndReferences( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // verify that Observation's LocalChanges are updated with new patient ID reference + val updatedObservationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(updatedObservationLocalChanges.size).isEqualTo(1) + val observationLocalChange = updatedObservationLocalChanges[0] + assertThat(observationLocalChange.type).isEqualTo(LocalChange.Type.INSERT) + val observationLocalChangePayload = + services.parser.parseResource(observationLocalChange.payload) as Observation + assertThat(observationLocalChangePayload.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + } + + @Test + fun updateResourceAndReferences_shouldUpdateReferencesInReferringLocalChangesOfUpdateType() = + runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + + // create an observation for the patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + addPerformer(Reference("Practitioner/123")) + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + database.update( + locallyCreatedPatientObservation.copy().apply { + performer = listOf(Reference("Patient/$locallyCreatedPatientResourceId")) + }, + ) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + // perform updates + database.updateResourceAndReferences( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // verify that Observation's LocalChanges are updated with new patient ID reference + val updatedObservationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(updatedObservationLocalChanges.size).isEqualTo(2) + val observationLocalChange2 = updatedObservationLocalChanges[1] + assertThat(observationLocalChange2.type).isEqualTo(LocalChange.Type.UPDATE) + // payload = + // [{"op":"replace","path":"\/performer\/0\/reference","value":"Patient\/remote-patient-1"}] + val observationLocalChange2Payload = JSONArray(observationLocalChange2.payload) + val patch = observationLocalChange2Payload.get(0) as JSONObject + val referenceValue = patch.getString("value") + assertThat(referenceValue).isEqualTo("Patient/$remotelyCreatedPatientResourceId") + } + + @Test + fun updateResourceAndReferences_shouldUpdateReferencesInReferringResource() = runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + + // create an observation for the patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + addPerformer(Reference("Practitioner/123")) + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + // perform updates + database.updateResourceAndReferences( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // verify that Observation is updated with new patient ID reference + val updatedObservationResource = + database.select( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) as Observation + assertThat(updatedObservationResource.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation is searchable i.e. ReferenceIndex is updated with new patient ID + // reference + val searchedObservations = + database.search( + Search(ResourceType.Observation) + .apply { + filter( + Observation.SUBJECT, + { value = "Patient/$remotelyCreatedPatientResourceId" }, + ) + } + .getQuery(), + ) + assertThat(searchedObservations.size).isEqualTo(1) + assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) + } + private companion object { const val mockEpochTimeStamp = 1628516301000 const val TEST_PATIENT_1_ID = "test_patient_1" 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 fdf5df0ce7..412da27b45 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 @@ -22,6 +22,8 @@ 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 import com.google.android.fhir.db.impl.DatabaseImpl.Companion.DATABASE_PASSPHRASE_NAME @@ -48,6 +50,7 @@ import org.junit.runner.RunWith class EncryptedDatabaseErrorTest { private val context: Context = ApplicationProvider.getApplicationContext() private val parser = FhirContext.forR4().newJsonParser() + private val terser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) private val resourceIndexer = ResourceIndexer(SearchParamDefinitionsProviderImpl()) @After @@ -64,6 +67,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = false, @@ -81,6 +85,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -111,6 +116,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -134,6 +140,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -163,6 +170,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -186,6 +194,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -218,6 +227,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -235,6 +245,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = 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 2eb7877e7b..56487f5ecb 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 @@ -29,6 +29,7 @@ import java.util.Date import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.Task import org.junit.Rule import org.junit.Test @@ -293,6 +294,81 @@ class ResourceDatabaseMigrationTest { assertThat(localChangeResourceId).isEqualTo(retrievedTaskResourceId) } + @Test + fun migrate7To8_should_execute_with_no_exception(): Unit = runBlocking { + val patientId = "patient-001" + val patientResourceUuid = "e2c79e28-ed4d-4029-a12c-108d1eb5bedb" + val patient: String = + Patient() + .apply { + id = patientId + addName(HumanName().apply { addGiven("Brad") }) + addGeneralPractitioner(Reference("Practitioner/123")) + managingOrganization = Reference("Organization/123") + meta.lastUpdated = Date() + } + .let { iParser.encodeResourceToString(it) } + + helper.createDatabase(DB_NAME, 7).apply { + val insertionDate = Date() + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Patient', '$patientResourceUuid', '$patientId', '${insertionDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.INSERT)}', '$patient' );", + ) + val updateDate = Date() + val patch = + "[{\"op\":\"replace\",\"path\":\"\\/generalPractitioner\\/0\\/reference\",\"value\":\"Practitioner\\/345\"}]" + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Patient', '$patientResourceUuid', '$patientId', '${updateDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.UPDATE)}', '$patch' );", + ) + val deleteDate = Date() + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Patient', '$patientResourceUuid', '$patientId', '${deleteDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.DELETE)}', '' );", + ) + close() + } + + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 8, true, MIGRATION_7_8) + + var localChange1Id: Long + var localChange2Id: Long + + var localChangeReferences: MutableMap> + + migratedDatabase.let { database -> + database.query("SELECT id FROM LocalChangeEntity").let { + it.moveToFirst() + localChange1Id = it.getLong(0) + it.moveToNext() + localChange2Id = it.getLong(0) + } + + database + .query( + "SELECT localChangeId, resourceReferenceValue FROM LocalChangeResourceReferenceEntity", + ) + .let { + var continueToNextRow = it.moveToFirst() + localChangeReferences = mutableMapOf() + while (continueToNextRow) { + val localChangeId = it.getLong(0) + val referenceValue = it.getString(1) + val existingList = localChangeReferences.getOrDefault(localChangeId, mutableListOf()) + existingList.add(referenceValue) + localChangeReferences[localChangeId] = existingList + continueToNextRow = it.moveToNext() + } + } + } + migratedDatabase.close() + assertThat(localChangeReferences).containsKey(localChange1Id) + assertThat(localChangeReferences).containsKey(localChange2Id) + assertThat(localChangeReferences[localChange1Id]!!.size).isEqualTo(2) + assertThat(localChangeReferences[localChange2Id]!!.size).isEqualTo(1) + assertThat(localChangeReferences[localChange1Id]!!) + .containsExactly("Practitioner/123", "Organization/123") + assertThat(localChangeReferences[localChange2Id]!!).containsExactly("Practitioner/345") + } + 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 new file mode 100644 index 0000000000..650f8acae6 --- /dev/null +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt @@ -0,0 +1,359 @@ +/* + * Copyright 2023 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.db.impl.dao + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import ca.uhn.fhir.util.FhirTerser +import com.google.android.fhir.db.impl.ResourceDatabase +import com.google.android.fhir.db.impl.entities.LocalChangeEntity +import com.google.android.fhir.db.impl.entities.ResourceEntity +import com.google.android.fhir.logicalId +import com.google.common.truth.Truth.assertThat +import java.time.Instant +import java.util.UUID +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class LocalChangeDaoTest { + private lateinit var database: ResourceDatabase + private lateinit var localChangeDao: LocalChangeDao + + @Before + fun setupDatabase() { + database = + Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + ResourceDatabase::class.java, + ) + .allowMainThreadQueries() + .build() + + localChangeDao = + database.localChangeDao().also { + it.iParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + it.fhirTerser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) + } + } + + @After + fun closeDatabase() { + database.close() + } + + @Test + fun addInsert_shouldAddLocalChangeAndLocalChangeReferences() = runBlocking { + val patientId = "Patient1" + val carePlanResourceUuid = UUID.randomUUID() + val carePlan = + CarePlan().apply { + id = "CarePlan1" + subject = Reference("Patient/$patientId") + activityFirstRep.detail.performer.add(Reference("Patient/$patientId")) + category = + listOf( + CodeableConcept( + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), + ) + } + val carePlanCreationTime = Instant.now() + localChangeDao.addInsert(carePlan, carePlanResourceUuid, carePlanCreationTime) + + val carePlanLocalChanges = localChangeDao.getLocalChanges(carePlanResourceUuid) + assertThat(carePlanLocalChanges.size).isEqualTo(1) + val carePlanLocalChange1 = carePlanLocalChanges[0] + 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)) + val carePlanLocalChange1Id = carePlanLocalChange1.id + + val localChangeResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange1Id) + assertThat(localChangeResourceReferences.size).isEqualTo(2) + assertThat(localChangeResourceReferences[0].resourceReferencePath).isEqualTo("subject") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$patientId") + assertThat(localChangeResourceReferences[1].resourceReferencePath) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[1].resourceReferenceValue) + .isEqualTo("Patient/$patientId") + } + + @Test + fun addUpdate_shouldAddLocalChangeAndLocalChangeReferences() = runBlocking { + val patientId = "Patient1" + val carePlanResourceUuid = UUID.randomUUID() + val originalCarePlan = + CarePlan().apply { + id = "CarePlan1" + subject = Reference("Patient/$patientId") + activityFirstRep.detail.performer.add(Reference("Patient/$patientId")) + category = + listOf( + CodeableConcept( + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), + ) + } + val carePlanCreationTime = Instant.now() + localChangeDao.addInsert(originalCarePlan, carePlanResourceUuid, carePlanCreationTime) + + val practitionerReference = "Practitioner/Practitioner123" + val modifiedCarePlan = + originalCarePlan.copy().apply { + author = Reference(practitionerReference) + activityFirstRep.detail.performer.clear() + activityFirstRep.detail.performer.add(Reference(practitionerReference)) + } + val carePlanUpdateTime = Instant.now() + localChangeDao.addUpdate( + oldEntity = + ResourceEntity( + id = 0, + lastUpdatedLocal = carePlanCreationTime, + lastUpdatedRemote = null, + versionId = null, + resourceId = originalCarePlan.logicalId, + resourceType = originalCarePlan.resourceType, + resourceUuid = carePlanResourceUuid, + serializedResource = localChangeDao.iParser.encodeResourceToString(originalCarePlan), + ), + updatedResource = modifiedCarePlan, + timeOfLocalChange = carePlanUpdateTime, + ) + + val carePlanLocalChanges = localChangeDao.getLocalChanges(carePlanResourceUuid) + assertThat(carePlanLocalChanges.size).isEqualTo(2) + val carePlanLocalChange1 = carePlanLocalChanges[0] + assertThat(carePlanLocalChange1.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange1.resourceId).isEqualTo(originalCarePlan.id) + assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) + assertThat(carePlanLocalChange1.payload) + .isEqualTo(localChangeDao.iParser.encodeResourceToString(originalCarePlan)) + + val carePlanLocalChange2 = carePlanLocalChanges[1] + assertThat(carePlanLocalChange2.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange2.resourceId).isEqualTo(originalCarePlan.id) + assertThat(carePlanLocalChange2.type).isEqualTo(LocalChangeEntity.Type.UPDATE) + assertThat(carePlanLocalChange2.payload) + .isEqualTo( + "[{\"op\":\"add\",\"path\":\"\\/author\",\"value\":{\"reference\":\"Practitioner\\/Practitioner123\"}}" + + ",{\"op\":\"replace\",\"path\":\"\\/activity\\/0\\/detail\\/performer\\/0\\/reference\",\"value\":\"Practitioner\\/Practitioner123\"}]", + ) + val carePlanLocalChange2Id = carePlanLocalChange2.id + + val localChangeResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange2Id) + assertThat(localChangeResourceReferences.size).isEqualTo(3) + assertThat(localChangeResourceReferences[0].resourceReferencePath) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$patientId") + assertThat(localChangeResourceReferences[1].resourceReferencePath).isEqualTo("author") + assertThat(localChangeResourceReferences[1].resourceReferenceValue) + .isEqualTo(practitionerReference) + assertThat(localChangeResourceReferences[2].resourceReferencePath) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[2].resourceReferenceValue) + .isEqualTo(practitionerReference) + } + + @Test + fun addDelete_shouldAddOnlyLocalChangeEntity() = runBlocking { + val patientId = "Patient1" + val carePlanResourceUuid = UUID.randomUUID() + val carePlan = + CarePlan().apply { + id = "CarePlan1" + subject = Reference("Patient/$patientId") + activityFirstRep.detail.performer.add(Reference("Patient/$patientId")) + category = + listOf( + CodeableConcept( + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), + ) + } + val carePlanCreationTime = Instant.now() + localChangeDao.addInsert(carePlan, carePlanResourceUuid, carePlanCreationTime) + + localChangeDao.addDelete( + resourceUuid = carePlanResourceUuid, + resourceType = carePlan.resourceType, + remoteVersionId = null, + resourceId = carePlan.id, + ) + + val carePlanLocalChanges = localChangeDao.getLocalChanges(carePlanResourceUuid) + assertThat(carePlanLocalChanges.size).isEqualTo(2) + val carePlanLocalChange1 = carePlanLocalChanges[0] + 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)) + + val carePlanLocalChange2 = carePlanLocalChanges[1] + assertThat(carePlanLocalChange2.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange2.resourceId).isEqualTo(carePlan.id) + assertThat(carePlanLocalChange2.type).isEqualTo(LocalChangeEntity.Type.DELETE) + assertThat(carePlanLocalChange2.payload).isEqualTo("") + val carePlanLocalChange2Id = carePlanLocalChange2.id + + val localChangeResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange2Id) + assertThat(localChangeResourceReferences.size).isEqualTo(0) + } + + @Test + fun updateResourceId_shouldUpdateLocalChangeAndLocalChangeReferences() = runBlocking { + val patientId = "Patient1" + val patientResourceUuid = UUID.randomUUID() + val patient = + Patient().apply { + gender = Enumerations.AdministrativeGender.MALE + id = patientId + } + val patientCreationTime = Instant.now() + localChangeDao.addInsert(patient, patientResourceUuid, patientCreationTime) + + val carePlanResourceUuid = UUID.randomUUID() + val originalCarePlan = + CarePlan().apply { + id = "CarePlan1" + subject = Reference("Patient/$patientId") + activityFirstRep.detail.performer.add(Reference("Patient/$patientId")) + category = + listOf( + CodeableConcept( + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), + ) + } + val carePlanCreationTime = Instant.now() + localChangeDao.addInsert(originalCarePlan, carePlanResourceUuid, carePlanCreationTime) + + val practitionerReference = "Practitioner/Practitioner123" + val modifiedCarePlan = + originalCarePlan.copy().apply { + author = Reference(practitionerReference) + activityFirstRep.detail.performer.clear() + activityFirstRep.detail.performer.add(Reference(practitionerReference)) + } + val carePlanUpdateTime = Instant.now() + localChangeDao.addUpdate( + oldEntity = + ResourceEntity( + id = 0, + lastUpdatedLocal = carePlanCreationTime, + lastUpdatedRemote = null, + versionId = null, + resourceId = originalCarePlan.logicalId, + resourceType = originalCarePlan.resourceType, + resourceUuid = carePlanResourceUuid, + serializedResource = localChangeDao.iParser.encodeResourceToString(originalCarePlan), + ), + updatedResource = modifiedCarePlan, + timeOfLocalChange = carePlanUpdateTime, + ) + + val updatedPatientId = "SyncedPatient1" + val updatedPatient = patient.copy().apply { id = updatedPatientId } + localChangeDao.updateResourceIdAndReferences( + resourceUuid = patientResourceUuid, + oldResource = patient, + updatedResource = updatedPatient, + ) + + // assert that Patient's new ID is reflected in the Patient Resource Change + val patientLocalChanges = localChangeDao.getLocalChanges(patientResourceUuid) + assertThat(patientLocalChanges.size).isEqualTo(1) + assertThat(patientLocalChanges[0].resourceId).isEqualTo(updatedPatientId) + + // assert that LocalChanges are still retrieved in the same sequence + val carePlanLocalChanges = localChangeDao.getLocalChanges(carePlanResourceUuid) + assertThat(carePlanLocalChanges.size).isEqualTo(2) + val carePlanLocalChange1 = carePlanLocalChanges[0] + assertThat(carePlanLocalChange1.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange1.resourceId).isEqualTo(originalCarePlan.id) + assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) + val updatedReferencesCarePlan = + originalCarePlan.copy().apply { + subject = Reference("Patient/$updatedPatientId") + activityFirstRep.detail.performer.clear() + activityFirstRep.detail.performer.add(Reference("Patient/$updatedPatientId")) + } + assertThat(carePlanLocalChange1.payload) + .isEqualTo(localChangeDao.iParser.encodeResourceToString(updatedReferencesCarePlan)) + val carePlanLocalChange1Id = carePlanLocalChange1.id + // assert that LocalChangeReferences are updated as well + val localChange1ResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange1Id) + assertThat(localChange1ResourceReferences.size).isEqualTo(2) + assertThat(localChange1ResourceReferences[0].resourceReferencePath).isEqualTo("subject") + assertThat(localChange1ResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$updatedPatientId") + assertThat(localChange1ResourceReferences[1].resourceReferencePath) + .isEqualTo("activity.detail.performer") + assertThat(localChange1ResourceReferences[1].resourceReferenceValue) + .isEqualTo("Patient/$updatedPatientId") + + val carePlanLocalChange2 = carePlanLocalChanges[1] + assertThat(carePlanLocalChange2.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange2.resourceId).isEqualTo(originalCarePlan.id) + assertThat(carePlanLocalChange2.type).isEqualTo(LocalChangeEntity.Type.UPDATE) + assertThat(carePlanLocalChange2.payload) + .isEqualTo( + "[{\"op\":\"add\",\"path\":\"\\/author\",\"value\":{\"reference\":\"Practitioner\\/Practitioner123\"}}" + + ",{\"op\":\"replace\",\"path\":\"\\/activity\\/0\\/detail\\/performer\\/0\\/reference\",\"value\":\"Practitioner\\/Practitioner123\"}]", + ) + val carePlanLocalChange2Id = carePlanLocalChange2.id + // assert that LocalChangeReferences are updated as well + val localChangeResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange2Id) + assertThat(localChangeResourceReferences.size).isEqualTo(3) + assertThat(localChangeResourceReferences[0].resourceReferencePath) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$updatedPatientId") + assertThat(localChangeResourceReferences[1].resourceReferencePath).isEqualTo("author") + assertThat(localChangeResourceReferences[1].resourceReferenceValue) + .isEqualTo(practitionerReference) + assertThat(localChangeResourceReferences[2].resourceReferencePath) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[2].resourceReferenceValue) + .isEqualTo(practitionerReference) + } +} 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 75203b31ed..2f2e3c7ca6 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirServices.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirServices.kt @@ -20,6 +20,7 @@ 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 import com.google.android.fhir.db.impl.DatabaseEncryptionKeyProvider.isDatabaseEncryptionSupported @@ -70,12 +71,14 @@ internal data class FhirServices( fun build(): FhirServices { val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + val terser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) val searchParamMap = searchParameters?.asMapOfResourceTypeToSearchParamDefinitions() ?: emptyMap() val db = DatabaseImpl( context = context, iParser = parser, + fhirTerser = terser, DatabaseConfig(inMemory, enableEncryption, databaseErrorStrategy), resourceIndexer = ResourceIndexer(SearchParamDefinitionsProviderImpl(searchParamMap)), ) diff --git a/engine/src/main/java/com/google/android/fhir/db/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt index fac887c229..03444c601b 100644 --- a/engine/src/main/java/com/google/android/fhir/db/Database.kt +++ b/engine/src/main/java/com/google/android/fhir/db/Database.kt @@ -23,6 +23,7 @@ import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.search.SearchQuery import java.time.Instant +import java.util.UUID import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -120,6 +121,18 @@ internal interface Database { /** Remove the [LocalChangeEntity] s with matching resource ids. */ suspend fun deleteUpdates(resources: List) + /** + * Updates the [ResourceEntity.serializedResource] and [ResourceEntity.resourceId] corresponding + * to the updatedResource. Updates all the [LocalChangeEntity] for this updated resource as well + * as all the [LocalChangeEntity] referring to this resource in their [LocalChangeEntity.payload] + * Updates the [ResourceEntity.serializedResource] for all the resources which refer to this + * updated resource. + */ + suspend fun updateResourceAndReferences( + currentResourceId: String, + updatedResource: Resource, + ) + /** Runs the block as a database transaction. */ suspend fun withTransaction(block: suspend () -> Unit) @@ -145,6 +158,18 @@ internal interface Database { */ suspend fun getLocalChanges(type: ResourceType, id: String): List + /** + * Retrieve a list of [LocalChange] for [ResourceEntity] with given UUID, which can be used to + * purge resource from database. If there is no local change for [ResourceEntity.resourceUuid], + * return an empty list. + * + * @param resourceUuid The resource UUID [ResourceEntity.resourceUuid] + * @return [List]<[LocalChange]> A list of local changes for given [resourceType] and + * [Resource.id] . If there is no local change for given [resourceType] and + * [ResourceEntity.resourceUuid], return empty list. + */ + suspend fun getLocalChanges(resourceUuid: UUID): List + /** * Purge resource from database based on resource type and id without any deletion of data from * the server. diff --git a/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt b/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt index 7b204c1116..a97c90c879 100644 --- a/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt +++ b/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt @@ -16,10 +16,13 @@ package com.google.android.fhir.db +import java.util.UUID + /** Thrown to indicate that the requested resource is not found. */ class ResourceNotFoundException : Exception { - val type: String - val id: String + lateinit var type: String + lateinit var id: String + lateinit var uuid: UUID constructor( type: String, @@ -30,8 +33,17 @@ class ResourceNotFoundException : Exception { this.id = id } - constructor(type: String, id: String) : super("Resource not found with type $type and id $id!") { + constructor( + type: String, + id: String, + ) : super("Resource not found with type $type and id $id!") { this.type = type this.id = id } + + constructor( + uuid: UUID, + ) : super("Resource not found with UUID $uuid!") { + this.uuid = uuid + } } 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 02aacd796a..47139cf052 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,6 +22,7 @@ import androidx.room.Room import androidx.room.withTransaction import androidx.sqlite.db.SimpleSQLiteQuery import ca.uhn.fhir.parser.IParser +import ca.uhn.fhir.util.FhirTerser import com.google.android.fhir.DatabaseErrorStrategy import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken @@ -34,6 +35,7 @@ import com.google.android.fhir.logicalId import com.google.android.fhir.search.SearchQuery import com.google.android.fhir.toLocalChange import java.time.Instant +import java.util.UUID import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -45,6 +47,7 @@ import org.hl7.fhir.r4.model.ResourceType internal class DatabaseImpl( private val context: Context, private val iParser: IParser, + private val fhirTerser: FhirTerser, databaseConfig: DatabaseConfig, private val resourceIndexer: ResourceIndexer, ) : com.google.android.fhir.db.Database { @@ -102,6 +105,7 @@ internal class DatabaseImpl( MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + MIGRATION_7_8, ) } .build() @@ -114,7 +118,11 @@ internal class DatabaseImpl( } } - private val localChangeDao = db.localChangeDao().also { it.iParser = iParser } + private val localChangeDao = + db.localChangeDao().also { + it.iParser = iParser + it.fhirTerser = fhirTerser + } override suspend fun insert(vararg resource: R): List { val logicalIds = mutableListOf() @@ -251,6 +259,85 @@ internal class DatabaseImpl( localChangeDao.discardLocalChanges(resources) } + override suspend fun updateResourceAndReferences( + currentResourceId: String, + updatedResource: Resource, + ) { + db.withTransaction { + val currentResourceEntity = selectEntity(updatedResource.resourceType, currentResourceId) + val oldResource = iParser.parseResource(currentResourceEntity.serializedResource) as Resource + val resourceUuid = currentResourceEntity.resourceUuid + updateResourceEntity(resourceUuid, updatedResource) + + val uuidsOfReferringResources = + updateLocalChangeResourceIdAndReferences( + resourceUuid = resourceUuid, + oldResource = oldResource, + updatedResource = updatedResource, + ) + + updateReferringResources( + referringResourcesUuids = uuidsOfReferringResources, + oldResource = oldResource, + updatedResource = updatedResource, + ) + } + } + + /** + * Calls the [ResourceDao] to update the [ResourceEntity] associated with this resource. The + * function updates the resource and resourceId of the [ResourceEntity] + */ + private suspend fun updateResourceEntity(resourceUuid: UUID, updatedResource: Resource) = + resourceDao.updateResourceWithUuid(resourceUuid, updatedResource) + + /** + * Update the [LocalChange]s to reflect the change in the resource ID. This primarily includes + * modifying the [LocalChange.resourceId] for the changes of the affected resource. Also, update + * any references in the [LocalChange] which refer to the affected resource. + * + * The function returns a [List<[UUID]>] which corresponds to the [ResourceEntity.resourceUuid] + * which contain references to the affected resource. + */ + private suspend fun updateLocalChangeResourceIdAndReferences( + resourceUuid: UUID, + oldResource: Resource, + updatedResource: Resource, + ) = + localChangeDao.updateResourceIdAndReferences( + resourceUuid = resourceUuid, + oldResource = oldResource, + updatedResource = updatedResource, + ) + + /** + * Update all [Resource] and their corresponding [ResourceEntity] which refer to the affected + * resource. The update of the references in the [Resource] is also expected to reflect in the + * [ReferenceIndex] i.e. the references used for search operations should also get updated to + * reflect the references with the new resource ID of the referred resource. + */ + private suspend fun updateReferringResources( + referringResourcesUuids: List, + oldResource: Resource, + updatedResource: Resource, + ) { + val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}" + val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" + referringResourcesUuids.forEach { resourceUuid -> + resourceDao.getResourceEntity(resourceUuid)?.let { + val referringResource = iParser.parseResource(it.serializedResource) as Resource + val updatedReferringResource = + addUpdatedReferenceToResource( + iParser, + referringResource, + oldReferenceValue, + updatedReferenceValue, + ) + resourceDao.updateResourceWithUuid(resourceUuid, updatedReferringResource) + } + } + } + override fun close() { db.close() } @@ -267,6 +354,12 @@ internal class DatabaseImpl( } } + override suspend fun getLocalChanges(resourceUuid: UUID): List { + return db.withTransaction { + localChangeDao.getLocalChanges(resourceUuid = resourceUuid).map { it.toLocalChange() } + } + } + override suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean) { db.withTransaction { // To check resource is present in DB else throw ResourceNotFoundException() diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt new file mode 100644 index 0000000000..31be98d1e1 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2023 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.db.impl + +import ca.uhn.fhir.parser.IParser +import org.hl7.fhir.r4.model.Resource +import org.json.JSONArray +import org.json.JSONObject + +internal fun addUpdatedReferenceToResource( + iParser: IParser, + resource: Resource, + outdatedReference: String, + updatedReference: String, +): Resource { + val resourceJsonObject = JSONObject(iParser.encodeResourceToString(resource)) + val updatedResource = replaceJsonValue(resourceJsonObject, outdatedReference, updatedReference) + return iParser.parseResource(updatedResource.toString()) as Resource +} + +internal fun replaceJsonValue( + jsonObject: JSONObject, + currentValue: String, + newValue: String, +): JSONObject { + val iterator: Iterator<*> = jsonObject.keys() + var key: String? + while (iterator.hasNext()) { + key = iterator.next() as String + // if object is just string we change value in key + if (jsonObject.optJSONArray(key) == null && jsonObject.optJSONObject(key) == null) { + if (jsonObject.optString(key) == currentValue) { + jsonObject.put(key, newValue) + } + } + + // if it's jsonobject + if (jsonObject.optJSONObject(key) != null) { + replaceJsonValue(jsonObject.getJSONObject(key), currentValue, newValue) + } + + // if it's jsonarray + if (jsonObject.optJSONArray(key) != null) { + val jArray = jsonObject.getJSONArray(key) + replaceJsonValue(jArray, currentValue, newValue) + } + } + return jsonObject +} + +internal fun replaceJsonValue( + jsonArray: JSONArray, + currentValue: String, + newValue: String, +): JSONArray { + for (i in 0 until jsonArray.length()) { + if (jsonArray.optJSONArray(i) != null) { + replaceJsonValue(jsonArray.getJSONArray(i), currentValue, newValue) + } else if (jsonArray.optJSONObject(i) != null) { + replaceJsonValue(jsonArray.getJSONObject(i), currentValue, newValue) + } else if (currentValue.equals(jsonArray.optString(i))) { + jsonArray.put(i, newValue) + } + } + return jsonArray +} + +internal fun lookForReferencesInJsonPatch(jsonObject: JSONObject): String? { + // "[{\"op\":\"replace\",\"path\":\"\\/basedOn\\/0\\/reference\",\"value\":\"CarePlan\\/345\"}]" + if (jsonObject.getString("path").endsWith("reference")) { + return jsonObject.getString("value") + } + return null +} + +internal fun extractAllValuesWithKey(lookupKey: String, jsonObject: JSONObject): List { + val iterator: Iterator<*> = jsonObject.keys() + var key: String? + val referenceValues = mutableListOf() + while (iterator.hasNext()) { + key = iterator.next() as String + // if object is just string we change value in key + if (jsonObject.optJSONArray(key) == null && jsonObject.optJSONObject(key) == null) { + if (key.equals(lookupKey)) { + referenceValues.add(jsonObject.getString(key)) + } + } + + // if it's jsonobject + if (jsonObject.optJSONObject(key) != null) { + referenceValues.addAll(extractAllValuesWithKey(lookupKey, jsonObject.getJSONObject(key))) + } + + // if it's jsonarray + if (jsonObject.optJSONArray(key) != null) { + referenceValues.addAll( + extractAllValuesWithKey(lookupKey, jsonObject.getJSONArray(key)), + ) + } + } + return referenceValues +} + +internal fun extractAllValuesWithKey(lookupKey: String, jArray: JSONArray): List { + val referenceValues = mutableListOf() + for (i in 0 until jArray.length()) { + if (jArray.optJSONObject(i) != null) { + referenceValues.addAll(extractAllValuesWithKey(lookupKey, jArray.getJSONObject(i))) + } else if (jArray.optJSONArray(i) != null) { + referenceValues.addAll( + extractAllValuesWithKey(lookupKey, jArray.getJSONArray(i)), + ) + } + } + return referenceValues +} 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 f69998fd49..3502ab0b8e 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 @@ -26,6 +26,7 @@ import com.google.android.fhir.db.impl.dao.ResourceDao import com.google.android.fhir.db.impl.entities.DateIndexEntity import com.google.android.fhir.db.impl.entities.DateTimeIndexEntity import com.google.android.fhir.db.impl.entities.LocalChangeEntity +import com.google.android.fhir.db.impl.entities.LocalChangeResourceReferenceEntity import com.google.android.fhir.db.impl.entities.NumberIndexEntity import com.google.android.fhir.db.impl.entities.PositionIndexEntity import com.google.android.fhir.db.impl.entities.QuantityIndexEntity @@ -34,6 +35,8 @@ import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.db.impl.entities.StringIndexEntity import com.google.android.fhir.db.impl.entities.TokenIndexEntity import com.google.android.fhir.db.impl.entities.UriIndexEntity +import org.json.JSONArray +import org.json.JSONObject @Database( entities = @@ -49,8 +52,9 @@ import com.google.android.fhir.db.impl.entities.UriIndexEntity NumberIndexEntity::class, LocalChangeEntity::class, PositionIndexEntity::class, + LocalChangeResourceReferenceEntity::class, ], - version = 7, + version = 8, exportSchema = true, ) @TypeConverters(DbTypeConverters::class) @@ -149,3 +153,58 @@ val MIGRATION_6_7 = ) } } + +/** Create [LocalChangeResourceReferenceEntity] */ +val MIGRATION_7_8 = + object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `LocalChangeResourceReferenceEntity` (`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)", + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_resourceReferenceValue` ON `LocalChangeResourceReferenceEntity` (`resourceReferenceValue`)", + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_localChangeId` ON `LocalChangeResourceReferenceEntity` (`localChangeId`)", + ) + + database.query("SELECT id,type,payload from LocalChangeEntity").let { + var continueIterating = it.moveToFirst() + while (continueIterating) { + val localChangeId = it.getLong(0) + val localChangeType = it.getInt(1) + val localChangePayload = it.getString(2) + val references = + when (localChangeType) { + LocalChangeEntity.Type.INSERT.value -> + extractAllValuesWithKey("reference", JSONObject(localChangePayload)) + LocalChangeEntity.Type.UPDATE.value -> { + val patchArray = JSONArray(localChangePayload) + val references = mutableListOf() + for (i in 0 until patchArray.length()) { + // look for any value with key "reference" in JsonPatch's value + references.addAll( + extractAllValuesWithKey("reference", patchArray.getJSONObject(i)), + ) + // look for value if the path of the JsonPatch is a reference path itself + // example: + // "[{\"op\":\"replace\",\"path\":\"\\/basedOn\\/0\\/reference\",\"value\":\"CarePlan\\/345\"}]" + lookForReferencesInJsonPatch(patchArray.getJSONObject(i))?.let { ref -> + references.add(ref) + } + } + references + } + LocalChangeEntity.Type.DELETE.value -> emptyList() + else -> throw IllegalArgumentException("Unknown LocalChangeType") + } + references.forEach { refValue -> + database.execSQL( + "INSERT INTO LocalChangeResourceReferenceEntity (localChangeId, resourceReferenceValue) VALUES ('$localChangeId', '$refValue' );", + ) + } + continueIterating = it.moveToNext() + } + } + } + } 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 24985ed9ed..43eac00442 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 @@ -18,16 +18,22 @@ package com.google.android.fhir.db.impl.dao import androidx.room.Dao import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import ca.uhn.fhir.parser.IParser +import ca.uhn.fhir.util.FhirTerser +import ca.uhn.fhir.util.ResourceReferenceInfo import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.github.fge.jsonpatch.diff.JsonDiff import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.db.impl.addUpdatedReferenceToResource import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.LocalChangeEntity.Type +import com.google.android.fhir.db.impl.entities.LocalChangeResourceReferenceEntity import com.google.android.fhir.db.impl.entities.ResourceEntity +import com.google.android.fhir.db.impl.replaceJsonValue import com.google.android.fhir.logicalId import com.google.android.fhir.versionId import java.time.Instant @@ -40,16 +46,25 @@ import timber.log.Timber /** * Dao for local changes made to a resource. One row in LocalChangeEntity corresponds to one change - * e.g. an INSERT or UPDATE. The UPDATES (diffs) are stored as RFC 6902 JSON patches. When a - * resource needs to be synced, all corresponding LocalChanges are 'squashed' to create a a single - * LocalChangeEntity to sync with the server. + * e.g. an INSERT or UPDATE. The UPDATES (diffs) are stored as RFC 6902 JSON patches. */ @Dao internal abstract class LocalChangeDao { lateinit var iParser: IParser + lateinit var fhirTerser: FhirTerser - @Insert abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity): Long + + @Query( + """ + UPDATE LocalChangeEntity + SET resourceId = :updatedResourceId + WHERE id = :localChangeId + """, + ) + abstract suspend fun updateResourceId(localChangeId: Long, updatedResourceId: String): Int @Transaction open suspend fun addInsert(resource: Resource, resourceUuid: UUID, timeOfLocalChange: Instant) { @@ -57,9 +72,9 @@ internal abstract class LocalChangeDao { val resourceType = resource.resourceType val resourceString = iParser.encodeResourceToString(resource) - addLocalChange( + val localChangeEntity = LocalChangeEntity( - id = 0, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = resourceUuid, @@ -67,13 +82,43 @@ internal abstract class LocalChangeDao { type = Type.INSERT, payload = resourceString, versionId = resource.versionId, - ), - ) + ) + + val localChangeReferences = + extractResourceReferences(resource).mapNotNull { resourceReferenceInfo -> + if (resourceReferenceInfo.resourceReference.referenceElement.value != null) { + LocalChangeResourceReferenceEntity( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = resourceReferenceInfo.name, + resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, + ) + } else { + null + } + } + createLocalChange(localChangeEntity, localChangeReferences) } - suspend fun addUpdate(oldEntity: ResourceEntity, resource: Resource, timeOfLocalChange: Instant) { - val resourceId = resource.logicalId - val resourceType = resource.resourceType + private suspend fun createLocalChange( + localChange: LocalChangeEntity, + localChangeReferences: List, + ) { + val localChangeId = addLocalChange(localChange) + if (localChangeReferences.isNotEmpty()) { + insertLocalChangeResourceReferences( + localChangeReferences.map { it.copy(localChangeId = localChangeId) }, + ) + } + } + + suspend fun addUpdate( + oldEntity: ResourceEntity, + updatedResource: Resource, + timeOfLocalChange: Instant, + ) { + val resourceId = updatedResource.logicalId + val resourceType = updatedResource.resourceType if ( !localChangeIsEmpty(resourceId, resourceType) && @@ -83,18 +128,18 @@ internal abstract class LocalChangeDao { "Unexpected DELETE when updating $resourceType/$resourceId. UPDATE failed.", ) } - val jsonDiff = - diff(iParser, iParser.parseResource(oldEntity.serializedResource) as Resource, resource) + val oldResource = iParser.parseResource(oldEntity.serializedResource) as Resource + val jsonDiff = diff(iParser, oldResource, updatedResource) if (jsonDiff.length() == 0) { Timber.i( - "New resource ${resource.resourceType}/${resource.id} is same as old resource. " + + "New resource ${updatedResource.resourceType}/${updatedResource.id} is same as old resource. " + "Not inserting UPDATE LocalChange.", ) return } - addLocalChange( + val localChangeEntity = LocalChangeEntity( - id = 0, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = oldEntity.resourceUuid, @@ -102,8 +147,18 @@ internal abstract class LocalChangeDao { type = Type.UPDATE, payload = jsonDiff.toString(), versionId = oldEntity.versionId, - ), - ) + ) + + val localChangeReferences = + extractReferencesDiff(oldResource, updatedResource).map { resourceReferenceInfo -> + LocalChangeResourceReferenceEntity( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = resourceReferenceInfo.name, + resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, + ) + } + createLocalChange(localChangeEntity, localChangeReferences) } suspend fun addDelete( @@ -112,9 +167,9 @@ internal abstract class LocalChangeDao { resourceType: ResourceType, remoteVersionId: String?, ) { - addLocalChange( + createLocalChange( LocalChangeEntity( - id = 0, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = resourceUuid, @@ -123,9 +178,52 @@ internal abstract class LocalChangeDao { payload = "", versionId = remoteVersionId, ), + emptyList(), ) } + private fun extractResourceReferences(resource: Resource) = + fhirTerser.getAllResourceReferences(resource).toSet() + + /** + * Extract the difference in the [ResourceReferenceInfo]s in the two versions of the resource. + * + * Two versions of a resource can vary in two ways in terms of the resources they refer: + * 1) A reference present in oldVersionResource is removed, hence, not present in + * newVersionResource. + * 2) A new reference is added to the oldVersionResource, hence, the reference is present in + * newVersionResource and not in oldVersionResource. + * + * We compute the differences of both the above kinds to return the entire set of differences. + * + * This method is useful to extract differences for UPDATE kind of [LocalChange] + * + * @param oldVersionResource: The older version of the resource + * @param newVersionResource: The new version of the resource + * @return A set of [ResourceReferenceInfo] containing the differences in references between the + * two resource versions. + */ + private fun extractReferencesDiff( + oldVersionResource: Resource, + newVersionResource: Resource, + ): Set { + require(oldVersionResource.resourceType.equals(newVersionResource.resourceType)) + val oldVersionResourceReferences = extractResourceReferences(oldVersionResource).toSet() + val newVersionResourceReferences = extractResourceReferences(newVersionResource).toSet() + return oldVersionResourceReferences.minus(newVersionResourceReferences) + + newVersionResourceReferences.minus(oldVersionResourceReferences) + } + + private fun Set.minus(set: Set) = + filter { ref -> + set.none { + it.name == ref.name && + it.resourceReference.referenceElement.value == + ref.resourceReference.referenceElement.value + } + } + .toSet() + @Query( """ SELECT type @@ -156,14 +254,23 @@ internal abstract class LocalChangeDao { """ SELECT * FROM LocalChangeEntity - ORDER BY LocalChangeEntity.id ASC""", + ORDER BY timestamp ASC""", ) abstract suspend fun getAllLocalChanges(): List @Query( """ - SELECT COUNT(*) + SELECT * FROM LocalChangeEntity + WHERE LocalChangeEntity.id IN (:ids) + ORDER BY timestamp ASC""", + ) + abstract suspend fun getLocalChanges(ids: List): List + + @Query( + """ + SELECT COUNT(*) + FROM LocalChangeEntity """, ) abstract suspend fun getLocalChangesCount(): Int @@ -198,7 +305,8 @@ internal abstract class LocalChangeDao { """ SELECT * FROM LocalChangeEntity - WHERE resourceId = :resourceId AND resourceType = :resourceType + WHERE resourceId = :resourceId AND resourceType = :resourceType + ORDER BY timestamp ASC """, ) abstract suspend fun getLocalChanges( @@ -208,7 +316,162 @@ internal abstract class LocalChangeDao { @Query( """ - SELECT * + SELECT * + FROM LocalChangeEntity + WHERE resourceUuid = :resourceUuid + ORDER BY timestamp ASC + """, + ) + abstract suspend fun getLocalChanges( + resourceUuid: UUID, + ): List + + @Query( + """ + SELECT * + FROM LocalChangeResourceReferenceEntity + WHERE resourceReferenceValue = :resourceReferenceValue + """, + ) + abstract suspend fun getLocalChangeReferencesWithValue( + resourceReferenceValue: String, + ): List + + @Query( + """ + SELECT * + FROM LocalChangeResourceReferenceEntity + WHERE localChangeId = :localChangeId + """, + ) + abstract suspend fun getReferencesForLocalChange( + localChangeId: Long, + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertLocalChangeResourceReferences( + resourceReferences: List, + ) + + /** + * Updates the resource IDs of the [LocalChange] of the updated resource. Updates [LocalChange] + * with references to the updated resource. + */ + suspend fun updateResourceIdAndReferences( + resourceUuid: UUID, + oldResource: Resource, + updatedResource: Resource, + ): List { + updateResourceIdInResourceLocalChanges( + resourceUuid = resourceUuid, + updatedResourceId = updatedResource.logicalId, + ) + return updateReferencesInLocalChange( + oldResource = oldResource, + updatedResource = updatedResource, + ) + } + + /** + * Updates the [LocalChangeEntity]s for the updated resource by updating the + * [LocalChangeEntity.resourceId]. + */ + private suspend fun updateResourceIdInResourceLocalChanges( + resourceUuid: UUID, + updatedResourceId: String, + ) = + getLocalChanges(resourceUuid).forEach { localChangeEntity -> + updateResourceId(localChangeEntity.id, updatedResourceId) + } + + /** + * Looks for [LocalChangeEntity] which refer to the updated resource through + * [LocalChangeResourceReferenceEntity]. For each [LocalChangeEntity] which contains reference to + * the updated resource in its payload, we update the payload with the reference and also update + * the corresponding [LocalChangeResourceReferenceEntity]. We delete the original + * [LocalChangeEntity] and create a new one with new [LocalChangeResourceReferenceEntity]s in its + * place. This method returns a list of the [ResourceEntity.resourceUuid] for all the resources + * whose [LocalChange] contained references to the oldResource + */ + private suspend fun updateReferencesInLocalChange( + oldResource: Resource, + updatedResource: Resource, + ): List { + val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}" + val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" + val referringLocalChangeIds = + getLocalChangeReferencesWithValue(oldReferenceValue).map { it.localChangeId }.distinct() + val referringLocalChanges = getLocalChanges(referringLocalChangeIds) + + referringLocalChanges.forEach { existingLocalChangeEntity -> + val updatedLocalChangeEntity = + replaceReferencesInLocalChangePayload( + localChange = existingLocalChangeEntity, + oldReference = oldReferenceValue, + updatedReference = updatedReferenceValue, + ) + .copy(id = DEFAULT_ID_VALUE) + val updatedLocalChangeReferences = + getReferencesForLocalChange(existingLocalChangeEntity.id).map { + localChangeResourceReferenceEntity -> + if (localChangeResourceReferenceEntity.resourceReferenceValue == oldReferenceValue) { + LocalChangeResourceReferenceEntity( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = localChangeResourceReferenceEntity.resourceReferencePath, + resourceReferenceValue = updatedReferenceValue, + ) + } else { + localChangeResourceReferenceEntity.copy( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + ) + } + } + discardLocalChanges(existingLocalChangeEntity.id) + createLocalChange(updatedLocalChangeEntity, updatedLocalChangeReferences) + } + return referringLocalChanges.map { it.resourceUuid }.distinct() + } + + private fun replaceReferencesInLocalChangePayload( + localChange: LocalChangeEntity, + oldReference: String, + updatedReference: String, + ): LocalChangeEntity { + return when (localChange.type) { + LocalChangeEntity.Type.INSERT -> { + val insertResourcePayload = iParser.parseResource(localChange.payload) as Resource + val updatedResourcePayload = + addUpdatedReferenceToResource( + iParser, + insertResourcePayload, + oldReference, + updatedReference, + ) + return localChange.copy( + payload = iParser.encodeResourceToString(updatedResourcePayload), + ) + } + LocalChangeEntity.Type.UPDATE -> { + val patchArray = JSONArray(localChange.payload) + val updatedPatchArray = JSONArray() + for (i in 0 until patchArray.length()) { + val updatedPatch = + replaceJsonValue(patchArray.getJSONObject(i), oldReference, updatedReference) + updatedPatchArray.put(updatedPatch) + } + return localChange.copy( + payload = updatedPatchArray.toString(), + ) + } + LocalChangeEntity.Type.DELETE -> localChange + } + } + + @Query( + """ + SELECT * FROM LocalChangeEntity WHERE resourceUuid = ( SELECT resourceUuid @@ -221,6 +484,10 @@ internal abstract class LocalChangeDao { abstract suspend fun getAllChangesForEarliestChangedResource(): List class InvalidLocalChangeException(message: String?) : Exception(message) + + companion object { + const val DEFAULT_ID_VALUE = 0L + } } /** Calculates the JSON patch between two [Resource] s. */ 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 b4213abef3..c31e49439c 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 @@ -74,7 +74,25 @@ internal abstract class ResourceDao { ) updateChanges(entity, resource) } - ?: throw ResourceNotFoundException(resource.resourceType.name, resource.id) + ?: throw ResourceNotFoundException( + resource.resourceType.name, + resource.id, + ) + } + + suspend fun updateResourceWithUuid(resourceUuid: UUID, updatedResource: Resource) { + getResourceEntity(resourceUuid)?.let { + val entity = + it.copy( + resourceId = updatedResource.logicalId, + serializedResource = iParser.encodeResourceToString(updatedResource), + lastUpdatedRemote = updatedResource.meta.lastUpdated?.toInstant() ?: it.lastUpdatedRemote, + ) + updateChanges(entity, updatedResource) + } + ?: throw ResourceNotFoundException( + resourceUuid, + ) } /** @@ -197,6 +215,17 @@ internal abstract class ResourceDao { resourceType: ResourceType, ): ResourceEntity? + @Query( + """ + SELECT * + FROM ResourceEntity + WHERE resourceUuid = :resourceUuid + """, + ) + abstract suspend fun getResourceEntity( + resourceUuid: UUID, + ): ResourceEntity? + @RawQuery abstract suspend fun getResources(query: SupportSQLiteQuery): List @RawQuery diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt new file mode 100644 index 0000000000..b02883254e --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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.db.impl.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + indices = + [ + Index(value = ["resourceReferenceValue"]), + // To avoid full table scans whenever parent table is modified. + Index(value = ["localChangeId"]), + ], + foreignKeys = + [ + ForeignKey( + entity = LocalChangeEntity::class, + parentColumns = ["id"], + childColumns = ["localChangeId"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.NO_ACTION, + deferred = true, + ), + ], +) +internal data class LocalChangeResourceReferenceEntity( + @PrimaryKey(autoGenerate = true) val id: Long, + val localChangeId: Long, + val resourceReferenceValue: String, + val resourceReferencePath: String?, +) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt index f5f3c8c7f7..15573f0b89 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt @@ -16,6 +16,23 @@ package com.google.android.fhir.sync.upload +/* + * Copyright 2023 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. + */ + +import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.Database import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Resource @@ -44,8 +61,10 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res override suspend fun consolidate(uploadSyncResult: UploadSyncResult) = when (uploadSyncResult) { is UploadSyncResult.Success -> { - database.deleteUpdates(uploadSyncResult.localChangeToken) - uploadSyncResult.resources.forEach { + database.deleteUpdates( + LocalChangeToken(uploadSyncResult.localChanges.flatMap { it.token.ids }), + ) + uploadSyncResult.responseResources.forEach { when (it) { is Bundle -> updateVersionIdAndLastUpdated(it) else -> updateVersionIdAndLastUpdated(it) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt index b5a8ee66d8..40a0936bd9 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt @@ -56,7 +56,7 @@ internal class Uploader(private val dataSource: DataSource) { } } - return UploadSyncResult.Success(token, successfulResponses) + return UploadSyncResult.Success(localChanges, successfulResponses) } private suspend fun handleUploadRequest(uploadRequest: UploadRequest): UploadRequestResult { @@ -95,8 +95,8 @@ internal class Uploader(private val dataSource: DataSource) { sealed class UploadSyncResult { data class Success( - val localChangeToken: LocalChangeToken, - val resources: List, + val localChanges: List, + val responseResources: List, ) : UploadSyncResult() data class Failure(val syncError: ResourceSyncException, val localChangeToken: LocalChangeToken) : diff --git a/engine/src/test/java/com/google/android/fhir/JsonUtilsTest.kt b/engine/src/test/java/com/google/android/fhir/JsonUtilsTest.kt new file mode 100644 index 0000000000..5c581ea704 --- /dev/null +++ b/engine/src/test/java/com/google/android/fhir/JsonUtilsTest.kt @@ -0,0 +1,263 @@ +/* + * Copyright 2023 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 + +import android.os.Build +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import com.google.android.fhir.db.impl.addUpdatedReferenceToResource +import com.google.android.fhir.db.impl.extractAllValuesWithKey +import com.google.android.fhir.db.impl.replaceJsonValue +import com.google.common.truth.Truth.assertThat +import junit.framework.TestCase +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.skyscreamer.jsonassert.JSONAssert + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class JsonUtilsTest : TestCase() { + + @Test + fun addUpdatedReferenceToResource_updatesReferenceInPatient() { + val oldPractitionerReference = "Practitioner/123" + val updatedPractitionerReference = "Practitioner/345" + val patient = + Patient().apply { + id = "f001" + addGeneralPractitioner(Reference(oldPractitionerReference)) + } + val updatedPatientResource = + addUpdatedReferenceToResource( + iParser, + patient, + oldPractitionerReference, + updatedPractitionerReference, + ) + as Patient + assertThat(updatedPatientResource.generalPractitioner.first().reference) + .isEqualTo(updatedPractitionerReference) + } + + @Test + fun addUpdatedReferenceToResource_updatesMultipleReferenceInCarePlan() { + val oldPatientReference = "Patient/123" + val updatedPatientReference = "Patient/345" + val carePlan = + CarePlan().apply { + id = "f001" + subject = (Reference(oldPatientReference)) + activityFirstRep.detail.performer.add(Reference(oldPatientReference)) + } + val updatedCarePlan = + addUpdatedReferenceToResource(iParser, carePlan, oldPatientReference, updatedPatientReference) + as CarePlan + assertThat(updatedCarePlan.subject.reference).isEqualTo(updatedPatientReference) + assertThat(updatedCarePlan.activityFirstRep.detail.performer.first().reference) + .isEqualTo(updatedPatientReference) + } + + @Test + fun replaceJsonValue_jsonObject1() { + val json = + JSONObject( + """ + { + "key1": "valueToBeReplaced", + "key2": { + "key3": { + "key4": [ + "valueToBeReplaced", + "otherValueNotToBeReplaced" + ] + } + } + } + """ + .trimIndent(), + ) + val updatedJson = replaceJsonValue(json, "valueToBeReplaced", "newValue") + val expectedJson = + JSONObject( + """ + { + "key1": "newValue", + "key2": { + "key3": { + "key4": [ + "newValue", + "otherValueNotToBeReplaced" + ] + } + } + } + """ + .trimIndent(), + ) + JSONAssert.assertEquals(updatedJson, expectedJson, false) + } + + @Test + fun replaceJsonValue_jsonObject2() { + val json = + JSONObject( + """ + { + "key1": "valueToBeReplaced", + "key2": { + "key3": { + "key4": [ + [ + "otherValueNotToBeReplaced", + "valueToBeReplaced" + ], + [ + "otherValueNotToBeReplaced" + ] + ] + } + } + } + """ + .trimIndent(), + ) + val updatedJson = replaceJsonValue(json, "valueToBeReplaced", "newValue") + val expectedJson = + JSONObject( + """ + { + "key1": "newValue", + "key2": { + "key3": { + "key4": [ + [ + "otherValueNotToBeReplaced", + "newValue" + ], + [ + "otherValueNotToBeReplaced" + ] + ] + } + } + } + """ + .trimIndent(), + ) + JSONAssert.assertEquals(updatedJson, expectedJson, false) + } + + @Test + fun replaceJsonValue_jsonObject3() { + val json = + JSONObject( + """ + { + "key1": "valueToBeReplaced", + "key2": { + "key3": { + "key4": [ + [ + { + "key5": "valueToBeReplaced" + } + ], + [ + { + "key6": "otherValueNotToBeReplaced" + } + ] + ] + } + } + } + """ + .trimIndent(), + ) + val updatedJson = replaceJsonValue(json, "valueToBeReplaced", "newValue") + val expectedJson = + JSONObject( + """ + { + "key1": "newValue", + "key2": { + "key3": { + "key4": [ + [ + { + "key5": "newValue" + } + ], + [ + { + "key6": "otherValueNotToBeReplaced" + } + ] + ] + } + } + } + """ + .trimIndent(), + ) + JSONAssert.assertEquals(updatedJson, expectedJson, false) + } + + @Test + fun extractAllValueWithKey_extractsValuesFromJson() { + val testJson = + """ + { + "key1": "newValue", + "reference": "testValue1", + "key2": { + "key3": { + "key4": [ + [ + { + "reference": "testValue2" + } + ], + [ + { + "key6": "otherValueNotToBeReplaced" + } + ] + ] + }, + "key5": { + "reference": "testValue3" + } + } + } + """ + .trimIndent() + val referenceValues = extractAllValuesWithKey("reference", JSONObject(testJson)) + assertThat(referenceValues.size).isEqualTo(3) + assertThat(referenceValues).containsExactly("testValue1", "testValue2", "testValue3") + } + + companion object { + val iParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + } +} 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 4cd475fd7e..98bfd4c218 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 @@ -326,7 +326,7 @@ class FhirEngineImplTest { .syncUpload(LocalChangesFetchMode.AllChanges) { localChanges.addAll(it) UploadSyncResult.Success( - LocalChangeToken(it.flatMap { it.token.ids }), + it, listOf(), ) } diff --git a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt index ebfb432d46..ece81eb07d 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt @@ -71,7 +71,7 @@ class FhirSynchronizerTest { `when`(uploader.upload(any())) .thenReturn( UploadSyncResult.Success( - LocalChangeToken(listOf()), + listOf(), listOf(), ), ) @@ -101,7 +101,7 @@ class FhirSynchronizerTest { `when`(uploader.upload(any())) .thenReturn( UploadSyncResult.Success( - LocalChangeToken(listOf()), + listOf(), listOf(), ), ) diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt index 7036bfeed9..d6e1b821ce 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt @@ -49,7 +49,7 @@ class UploaderTest { .upload(localChanges) assertThat(result).isInstanceOf(UploadSyncResult.Success::class.java) - with(result as UploadSyncResult.Success) { assertThat(resources).hasSize(1) } + with(result as UploadSyncResult.Success) { assertThat(responseResources).hasSize(1) } } @Test From 61b73568a359ac3e28bced2b44799d1db19e2567 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Oct 2023 18:57:11 +0200 Subject: [PATCH 31/69] Bump app.cash.licensee:licensee-gradle-plugin from 1.3.0 to 1.8.0 (#2282) Bumps [app.cash.licensee:licensee-gradle-plugin](https://github.com/cashapp/licensee) from 1.3.0 to 1.8.0. - [Release notes](https://github.com/cashapp/licensee/releases) - [Changelog](https://github.com/cashapp/licensee/blob/trunk/CHANGELOG.md) - [Commits](https://github.com/cashapp/licensee/compare/1.3.0...1.8.0) --- updated-dependencies: - dependency-name: app.cash.licensee:licensee-gradle-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index d613124145..46ef179257 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -13,7 +13,7 @@ dependencies { implementation("com.android.tools.build:gradle:8.1.2") - implementation("app.cash.licensee:licensee-gradle-plugin:1.3.0") + implementation("app.cash.licensee:licensee-gradle-plugin:1.8.0") implementation("com.osacky.flank.gradle:fladle:0.17.4") implementation("com.spotify.ruler:ruler-gradle-plugin:1.4.0") From ee30854bd989fcb22e5a32f54505ff57cf065c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 20:01:41 +0200 Subject: [PATCH 32/69] Bump Jackson from 2.15.2 to 2.15.3 (#2301) --- settings.gradle.kts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 6993d48be4..585f3f934f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,13 +31,13 @@ if (kokoroRun == true) { // NECESSARY force of the Jackson to run generateSearchParams in the new version of HAPI (6.8) buildscript { dependencies { - classpath("com.fasterxml.jackson.core:jackson-core:2.15.2") - classpath("com.fasterxml.jackson.core:jackson-annotations:2.15.2") - classpath("com.fasterxml.jackson.core:jackson-databind:2.15.2") - classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.15.2") - classpath("com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.15.2") - classpath("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2") - classpath("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2") + classpath("com.fasterxml.jackson.core:jackson-core:2.15.3") + classpath("com.fasterxml.jackson.core:jackson-annotations:2.15.3") + classpath("com.fasterxml.jackson.core:jackson-databind:2.15.3") + classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.15.3") + classpath("com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.15.3") + classpath("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.3") + classpath("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") } } From 73ac0fa9734a1355a4736169887cd432d70bb556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 21:48:05 +0200 Subject: [PATCH 33/69] Introduce Version Catalog, initially intentionally only for Glide (see #2302) (#2306) --- buildSrc/src/main/kotlin/Dependencies.kt | 8 -------- datacapture/build.gradle.kts | 2 +- gradle/libs.versions.toml | 11 +++++++++++ 3 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 gradle/libs.versions.toml diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index a6b3d99113..34711c988f 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -41,10 +41,6 @@ object Dependencies { "org.opencds.cqf.fhir:cqf-fhir-utility:${Versions.Cql.clinicalReasoning}" } - object Glide { - const val glide = "com.github.bumptech.glide:glide:${Versions.Glide.glide}" - } - object HapiFhir { const val fhirBase = "ca.uhn.hapi.fhir:hapi-fhir-base:${Versions.hapiFhir}" const val fhirClient = "ca.uhn.hapi.fhir:hapi-fhir-client:${Versions.hapiFhir}" @@ -218,10 +214,6 @@ object Dependencies { const val clinicalReasoning = "3.0.0-PRE9-SNAPSHOT" } - object Glide { - const val glide = "4.14.2" - } - object Kotlin { const val kotlinCoroutinesCore = "1.7.2" const val stdlib = "1.8.20" diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts index 78ba2939d9..0e829e662d 100644 --- a/datacapture/build.gradle.kts +++ b/datacapture/build.gradle.kts @@ -86,7 +86,7 @@ dependencies { implementation(Dependencies.Androidx.constraintLayout) implementation(Dependencies.Androidx.coreKtx) implementation(Dependencies.Androidx.fragmentKtx) - implementation(Dependencies.Glide.glide) + implementation(libs.glide) implementation(Dependencies.HapiFhir.guavaCaching) implementation(Dependencies.HapiFhir.validation) { exclude(module = "commons-logging") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000000..c43133a190 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,11 @@ +# see https://docs.gradle.org/current/userguide/platforms.html + +[versions] +glide = "4.14.2" + +[libraries] +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } + +[bundles] + +[plugins] From 748a5db5da49eea3e8a378fe22aba6100ea7b03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 22:33:19 +0200 Subject: [PATCH 34/69] Minor clean-up to align CodeQL & Build GitHub Actions (#2313) --- .github/workflows/build.yml | 27 ++++++++++++++++++++------- .github/workflows/codeql.yaml | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ffbca252b..22419da6dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -name: "GitHub Actions: Build" +name: "Build" # Controls when the action will run. Triggers the workflow on push or pull request # events for the `master` branch @@ -22,18 +22,30 @@ on: tags: build* pull_request: + # The branches below must be a subset of the branches above branches: [ master ] + schedule: + # Run once a week (even if no new code or PRs) to detect random regressions + - cron: '12 13 * * 2' + env: # Allow precise monitoring of the save/restore of Gradle User Home by `gradle-build-action` # See https://github.com/marketplace/actions/gradle-build-action?version=v2.1.1#cache-debugging-and-analysis GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX: "fhir" # change this to invalidate cache -# A workflow run is made up of one or more jobs that can run sequentially or in parallel + + # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # Build will compile APK, test APK and run tests, lint, etc. build: - runs-on: ubuntu-22.04-8core + timeout-minutes: 60 + permissions: + actions: read + contents: read + + strategy: + fail-fast: false # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -49,17 +61,18 @@ jobs: # Fetch origin/master for spotless ratchet to work # https://github.com/diffplug/spotless/issues/1242 fetch-depth: 0 + - name: Setup machine uses: ./.github/actions/commonSetup - name: Spotless check - run: ./gradlew spotlessCheck --scan --stacktrace + run: ./gradlew spotlessCheck --scan --full-stacktrace - - name: Build with Gradle - run: ./gradlew build --scan --stacktrace + - name: Build (full) with Gradle + run: ./gradlew build --scan --full-stacktrace - name: Check with Gradle - run: ./gradlew check --scan --stacktrace + run: ./gradlew check --scan --full-stacktrace - name: Release artifacts to local repo run: ./gradlew publishReleasePublicationToCIRepository --scan diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 25e4197f6b..0df5358f2c 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -1,5 +1,24 @@ +# Copyright 2020 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. + name: "CodeQL" +# NB: This GitHub Action for https://codeql.github.com seems to be +# a bit "special"; it does not appear to be (easily) possible to just +# integrate and run this as part of the main build.yaml action; see +# https://github.com/google/android-fhir/issues/2310. + on: push: branches: [ "master" ] From 1cacd33a6482fb929e3eb39c77c17cce67a863b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Thu, 26 Oct 2023 23:53:03 +0200 Subject: [PATCH 35/69] Ignore known flaky test on all API Levels (re. #1482) (#2318) --- ...nnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt index 7c05378caa..4c3b530c55 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt @@ -58,6 +58,7 @@ import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test @@ -262,7 +263,7 @@ class QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest { } @Test - @SdkSuppress(minSdkVersion = 33) // TODO https://github.com/google/android-fhir/issues/1482 FIXME + @Ignore // TODO https://github.com/google/android-fhir/issues/1482 FIXME fun selectOther_shouldScrollDownToShowAddAnotherAnswer() { val questionnaireItem = answerOptions( From de43c9f486eddeb3741ba506715c1968f86ee31f Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Sun, 29 Oct 2023 02:02:17 -0400 Subject: [PATCH 36/69] disable jetifier and bump to use latest version of engine (#2317) --- codelabs/datacapture/gradle.properties | 4 +--- codelabs/engine/README.md | 2 +- codelabs/engine/app/build.gradle.kts | 2 +- codelabs/engine/gradle.properties | 2 -- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/codelabs/datacapture/gradle.properties b/codelabs/datacapture/gradle.properties index 98bed167dc..f20a5218ef 100644 --- a/codelabs/datacapture/gradle.properties +++ b/codelabs/datacapture/gradle.properties @@ -15,7 +15,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md index 31ab482473..9b116e4cbb 100644 --- a/codelabs/engine/README.md +++ b/codelabs/engine/README.md @@ -125,7 +125,7 @@ file of your project: dependencies { // ... - implementation("com.google.android.fhir:engine:0.1.0-beta04") + implementation("com.google.android.fhir:engine:0.1.0-beta05") } ``` diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts index fcae511bae..b5ab70788a 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.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - implementation("com.google.android.fhir:engine:0.1.0-beta04") + implementation("com.google.android.fhir:engine:0.1.0-beta05") implementation("androidx.fragment:fragment-ktx:1.6.1") } diff --git a/codelabs/engine/gradle.properties b/codelabs/engine/gradle.properties index 98bed167dc..252175276f 100644 --- a/codelabs/engine/gradle.properties +++ b/codelabs/engine/gradle.properties @@ -15,7 +15,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official \ No newline at end of file From cf70b51a4b444ddf3ed125f57804b4ef3955df5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Mon, 30 Oct 2023 11:08:59 +0100 Subject: [PATCH 37/69] Factor out runFlank (re. #2321) (#2323) Co-authored-by: Jing Tang --- .github/workflows/runFlank.sh | 31 +++++++++++++++++++++++++++++++ kokoro/gcp_ubuntu/kokoro_build.sh | 11 +---------- 2 files changed, 32 insertions(+), 10 deletions(-) create mode 100755 .github/workflows/runFlank.sh diff --git a/.github/workflows/runFlank.sh b/.github/workflows/runFlank.sh new file mode 100755 index 0000000000..76e50e6a64 --- /dev/null +++ b/.github/workflows/runFlank.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Fail on any error. +set -e + +lib_names=("workflow:benchmark" "engine:benchmark" "datacapture" "engine" "knowledge" "workflow") +firebase_pids=() + +for lib_name in "${lib_names[@]}"; do + ./gradlew :$lib_name:runFlank --scan --stacktrace & + firebase_pids+=("$!") +done + +for firebase_pid in ${firebase_pids[*]}; do + wait $firebase_pid +done diff --git a/kokoro/gcp_ubuntu/kokoro_build.sh b/kokoro/gcp_ubuntu/kokoro_build.sh index bc7c4eb45b..d3ed56d6b1 100755 --- a/kokoro/gcp_ubuntu/kokoro_build.sh +++ b/kokoro/gcp_ubuntu/kokoro_build.sh @@ -92,16 +92,7 @@ function build_only() { function device_tests() { ./gradlew packageDebugAndroidTest --scan --stacktrace ./gradlew packageReleaseAndroidTest --scan --stacktrace - local lib_names=("workflow:benchmark" "engine:benchmark" "datacapture" "engine" "knowledge" "workflow") - firebase_pids=() - for lib_name in "${lib_names[@]}"; do - ./gradlew :$lib_name:runFlank --scan --stacktrace & - firebase_pids+=("$!") - done - - for firebase_pid in ${firebase_pids[*]}; do - wait $firebase_pid - done + .github/workflows/runFlank.sh } # Generates JaCoCo reports and uploads to Codecov: https://about.codecov.io/ From c26f6545c969c6f4946db555deecca4412bdc24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Mon, 30 Oct 2023 18:37:13 +0100 Subject: [PATCH 38/69] Replace styfle/cancel-workflow-action with new GitHub `concurrency:` standard (#2316) see https://github.com/styfle/cancel-workflow-action/blob/main/README.md --- .github/workflows/build.yml | 11 +++++++---- .github/workflows/codeql.yaml | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22419da6dc..cef0fbf978 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,13 @@ env: GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX: "fhir" # change this to invalidate cache +concurrency: + # github.head_ref uniquely identifies Pull Requests (but is not available when building branches like main or master) + # github.ref is the fallback used when building for workflows triggered by push + # Note that || are fallback values (not "concatenations") + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true # Use e.g. ${{ github.ref != 'refs/heads/main' }} (or master, until #2180) to only cancel for PRs not on branch + # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # Build will compile APK, test APK and run tests, lint, etc. @@ -49,10 +56,6 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: - - name: Cancel previous - uses: styfle/cancel-workflow-action@0.12.0 - with: - access_token: ${{ github.token }} # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout Repo diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 0df5358f2c..1f90d2cb89 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -28,6 +28,10 @@ on: schedule: - cron: '32 13 * * 2' +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref || || github.run_id }} + cancel-in-progress: true + jobs: analyze: name: Analyze From 3b0cb2060286b4410286c83b3c3ed36bdde4d2ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 18:42:43 +0000 Subject: [PATCH 39/69] Bump com.github.bumptech.glide:glide from 4.14.2 to 4.16.0 (#2325) Bumps [com.github.bumptech.glide:glide](https://github.com/bumptech/glide) from 4.14.2 to 4.16.0. - [Release notes](https://github.com/bumptech/glide/releases) - [Commits](https://github.com/bumptech/glide/compare/v4.14.2...v4.16.0) --- updated-dependencies: - dependency-name: com.github.bumptech.glide:glide dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c43133a190..ac89294412 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ # see https://docs.gradle.org/current/userguide/platforms.html [versions] -glide = "4.14.2" +glide = "4.16.0" [libraries] glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } From 251f56249e5eeac54824c1ee9e71024e8ebcebd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Nov 2023 12:56:41 +0000 Subject: [PATCH 40/69] Bump com.android.tools.build:gradle from 8.1.2 to 8.1.4 (#2333) Bumps com.android.tools.build:gradle from 8.1.2 to 8.1.4. --- updated-dependencies: - dependency-name: com.android.tools.build:gradle dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 46ef179257..52f93a7af6 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -11,7 +11,7 @@ repositories { dependencies { implementation("com.diffplug.spotless:spotless-plugin-gradle:6.22.0") - implementation("com.android.tools.build:gradle:8.1.2") + implementation("com.android.tools.build:gradle:8.1.4") implementation("app.cash.licensee:licensee-gradle-plugin:1.8.0") implementation("com.osacky.flank.gradle:fladle:0.17.4") From 76ecd8c746b17b2297241683516a100812a5dbe2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:36:31 +0000 Subject: [PATCH 41/69] Bump com.fasterxml.jackson.module:jackson-module-jaxb-annotations (#2334) Bumps [com.fasterxml.jackson.module:jackson-module-jaxb-annotations](https://github.com/FasterXML/jackson-modules-base) from 2.15.3 to 2.16.0. - [Commits](https://github.com/FasterXML/jackson-modules-base/compare/jackson-modules-base-2.15.3...jackson-modules-base-2.16.0) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.module:jackson-module-jaxb-annotations dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 585f3f934f..821ae93ccd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,7 +35,7 @@ buildscript { classpath("com.fasterxml.jackson.core:jackson-annotations:2.15.3") classpath("com.fasterxml.jackson.core:jackson-databind:2.15.3") classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.15.3") - classpath("com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.15.3") + classpath("com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.16.0") classpath("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.3") classpath("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") } From 704d753daeec67f05dbeea9e138cd9ada686043b Mon Sep 17 00:00:00 2001 From: aditya-07 Date: Wed, 22 Nov 2023 22:45:36 +0530 Subject: [PATCH 42/69] Updated the nested query to be able to use the index for searching instead of a full table scan (#2335) --- .../com/google/android/fhir/search/MoreSearch.kt | 6 ++++-- .../com/google/android/fhir/search/SearchTest.kt | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt index 60b3fe1472..a75e2dc126 100644 --- a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt @@ -293,6 +293,7 @@ internal fun Search.getQuery( filterArgs.addAll(it.args) } val whereArgs = mutableListOf() + val nestedArgs = mutableListOf() val query = when { isCount -> { @@ -311,11 +312,12 @@ internal fun Search.getQuery( nestedContext != null -> { whereArgs.add(nestedContext.param.paramName) val start = "${nestedContext.parentType.name}/".length + 1 + nestedArgs.add(nestedContext.parentType.name) // spotless:off """ SELECT resourceUuid FROM ResourceEntity a - WHERE a.resourceId IN ( + WHERE a.resourceType = ? AND a.resourceId IN ( SELECT substr(a.index_value, $start) FROM ReferenceIndexEntity a $sortJoinStatement @@ -342,7 +344,7 @@ internal fun Search.getQuery( .split("\n") .filter { it.isNotBlank() } .joinToString("\n") { it.trim() } - return SearchQuery(query, sortArgs + type.name + whereArgs + filterArgs + limitArgs) + return SearchQuery(query, nestedArgs + sortArgs + type.name + whereArgs + filterArgs + limitArgs) } private val Order?.sqlString: String diff --git a/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt b/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt index 420e5e7da8..34e9d71692 100644 --- a/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt +++ b/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt @@ -1827,7 +1827,7 @@ class SearchTest { AND a.resourceUuid IN ( SELECT resourceUuid FROM ResourceEntity a - WHERE a.resourceId IN ( + WHERE a.resourceType = ? AND a.resourceId IN ( SELECT substr(a.index_value, 9) FROM ReferenceIndexEntity a WHERE a.resourceType = ? AND a.index_name = ? @@ -1844,6 +1844,7 @@ class SearchTest { assertThat(query.args) .isEqualTo( listOf( + ResourceType.Patient.name, ResourceType.Patient.name, ResourceType.Condition.name, Condition.SUBJECT.paramName, @@ -1906,7 +1907,7 @@ class SearchTest { AND a.resourceUuid IN ( SELECT resourceUuid FROM ResourceEntity a - WHERE a.resourceId IN ( + WHERE a.resourceType = ? AND a.resourceId IN ( SELECT substr(a.index_value, 9) FROM ReferenceIndexEntity a WHERE a.resourceType = ? AND a.index_name = ? @@ -1931,6 +1932,7 @@ class SearchTest { ResourceType.Patient.name, Patient.ADDRESS_COUNTRY.paramName, "IN", + ResourceType.Patient.name, ResourceType.Immunization.name, Immunization.PATIENT.paramName, ResourceType.Immunization.name, @@ -1974,7 +1976,7 @@ class SearchTest { AND a.resourceUuid IN ( SELECT resourceUuid FROM ResourceEntity a - WHERE a.resourceId IN ( + WHERE a.resourceType = ? AND a.resourceId IN ( SELECT substr(a.index_value, 9) FROM ReferenceIndexEntity a WHERE a.resourceType = ? AND a.index_name = ? @@ -1986,7 +1988,7 @@ class SearchTest { ) AND a.resourceUuid IN( SELECT resourceUuid FROM ResourceEntity a - WHERE a.resourceId IN ( + WHERE a.resourceType = ? AND a.resourceId IN ( SELECT substr(a.index_value, 9) FROM ReferenceIndexEntity a WHERE a.resourceType = ? AND a.index_name = ? @@ -2003,6 +2005,7 @@ class SearchTest { assertThat(query.args) .isEqualTo( listOf( + ResourceType.Patient.name, ResourceType.Patient.name, ResourceType.Condition.name, Condition.SUBJECT.paramName, @@ -2010,6 +2013,7 @@ class SearchTest { Condition.CODE.paramName, "44054006", "http://snomed.info/sct", + ResourceType.Patient.name, ResourceType.Condition.name, Condition.SUBJECT.paramName, ResourceType.Condition.name, From ff74a7489975c4b21887110f10572feafbeb55b1 Mon Sep 17 00:00:00 2001 From: Hugo Miloszewski Date: Thu, 30 Nov 2023 12:39:21 +0000 Subject: [PATCH 43/69] Added interfaces and implementations for IPS & SHL generation (#2202) * add document lib folder * Added interfaces and data classes * Added utils, layouts and implementations of interfaces * Added tests * Need to refactor test-data * Tests seperated into relevant files * Added resources folder for test data * Renamed example bundle file * Renamed minimal bundle variable * Added test-data directory * Changed vision dependencies to mlkit * Refactored shlData param names and added SHLDecoder implementation * Reduced PR to only include a single feature - added more unit tests for SHL generation * Removed unnecessary res files * Made util classes internal and used dependency injection to allow for simpler unit testing * GenSHLUtils refactored to use Retrofit for HTTP requests and Dependencies tidied * Removed the global scope and cut the generateAndPostPayload function to make it more readable * Moved Retrofit interfaces into interface directory * Commented SHL Retrofit interface * Gradle checks run * Removed linkGenerator * Changed from using java.util.Base64 to android.util.Base64 so the minimum API level can be 24 instead of 26 * Retrofit service is now passed into the utils constructor - can now be mocked in tests * Moved Title data class into the IPSDocument file and removed network request tests * Improved the kdocs for the Title and IPSDoc data classes * Improved kdoc for the SHLData class - link to docs now included * Removed unnecessary Authenticator in RetrofitSHLService * Separated encryption methods into separate object, removed printlns and replaced Log with Timber * Merged master into branch - removed fhirEngineBeta04 dependency * Created new interface for QRGen (now separated from the link generation process) * Implemented SHLinkGen function and moved all QR related code to new file * Changed code structure and removed used dependency * Simplified SHLData data class to only include required fields for generation & added serverBaseUrl variable to genSHLink function/ * Dependency updated to fhirEngineBeta05 so gzipUploadInterceptor can be imported * Added unit test for postPayload * Corrected postPayload test so that it now checks that a non-empty JSON is returned when the responseBody is non-empty * More unit tests added * Removed handler and context arguments for Impl files & added tests for QR implementation * Including an IPS viewer in the SHL is now optional and can be chosen by developer * Unit tests changed to only test the public function the user will call * Added icu4c license * Removed beta5 dependency * Added docs to interfaces * Added desugar dependency and ran sA * Changed postPayload to a void function * run test added to all tests * fix datetime issue --------- Co-authored-by: omarismail --- buildSrc/src/main/kotlin/Dependencies.kt | 8 + buildSrc/src/main/kotlin/LicenseeConfig.kt | 16 + document/.gitignore | 1 + document/build.gradle.kts | 58 +++ document/consumer-rules.pro | 0 document/proguard-rules.pro | 21 + document/src/main/AndroidManifest.xml | 4 + .../IPSDocument.kt | 54 +++ .../RetrofitSHLService.kt | 107 +++++ .../generate/EncryptionUtils.kt | 48 ++ .../generate/QRGenerator.kt | 26 + .../generate/QRGeneratorImpl.kt | 36 ++ .../generate/QRGeneratorUtils.kt | 90 ++++ .../generate/SHLinkGenerationData.kt | 37 ++ .../generate/SHLinkGenerator.kt | 29 ++ .../generate/SHLinkGeneratorImpl.kt | 154 ++++++ document/src/main/res/drawable/smart_logo.xml | 55 +++ .../fhir/document/EncryptionUtilsTest.kt | 51 ++ .../fhir/document/QRGeneratorImplTest.kt | 90 ++++ .../fhir/document/SHLinkGeneratorImplTest.kt | 266 +++++++++++ document/test-data/bundleMinimal.json | 443 ++++++++++++++++++ settings.gradle.kts | 2 + 22 files changed, 1596 insertions(+) create mode 100644 document/.gitignore create mode 100644 document/build.gradle.kts create mode 100644 document/consumer-rules.pro create mode 100644 document/proguard-rules.pro create mode 100644 document/src/main/AndroidManifest.xml create mode 100644 document/src/main/java/com.google.android.fhir.document/IPSDocument.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/RetrofitSHLService.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/generate/EncryptionUtils.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/generate/QRGenerator.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/generate/QRGeneratorImpl.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/generate/QRGeneratorUtils.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/generate/SHLinkGenerationData.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/generate/SHLinkGenerator.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/generate/SHLinkGeneratorImpl.kt create mode 100644 document/src/main/res/drawable/smart_logo.xml create mode 100644 document/src/test/java/com/google/android/fhir/document/EncryptionUtilsTest.kt create mode 100644 document/src/test/java/com/google/android/fhir/document/QRGeneratorImplTest.kt create mode 100644 document/src/test/java/com/google/android/fhir/document/SHLinkGeneratorImplTest.kt create mode 100644 document/test-data/bundleMinimal.json diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 34711c988f..f5174a7b15 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -115,6 +115,7 @@ object Dependencies { object Retrofit { const val coreRetrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofit}" + const val gsonConverter = "com.squareup.retrofit2:converter-gson:${Versions.retrofit}" } object Room { @@ -159,6 +160,9 @@ object Dependencies { const val woodstox = "com.fasterxml.woodstox:woodstox-core:${Versions.woodstox}" const val xerces = "xerces:xercesImpl:${Versions.xerces}" + const val zxing = "com.google.zxing:core:${Versions.zxing}" + const val nimbus = "com.nimbusds:nimbus-jose-jwt:${Versions.nimbus}" + // Dependencies for testing go here object AndroidxTest { const val archCore = "androidx.arch.core:core-testing:${Versions.AndroidxTest.archCore}" @@ -245,6 +249,7 @@ object Dependencies { 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" const val timber = "5.0.1" const val truth = "1.1.5" @@ -252,6 +257,9 @@ object Dependencies { const val xerces = "2.12.2" const val xmlUnit = "2.9.1" + const val zxing = "3.4.1" + const val nimbus = "9.31" + // Test dependencies object AndroidxTest { const val benchmarkJUnit = "1.1.1" diff --git a/buildSrc/src/main/kotlin/LicenseeConfig.kt b/buildSrc/src/main/kotlin/LicenseeConfig.kt index f17f44ca1c..554c26a87a 100644 --- a/buildSrc/src/main/kotlin/LicenseeConfig.kt +++ b/buildSrc/src/main/kotlin/LicenseeConfig.kt @@ -115,10 +115,14 @@ fun Project.configureLicensee() { // Utilities // https://developers.google.com/android/reference/com/google/android/gms/common/package-summary allowDependency("com.google.android.gms", "play-services-base", "17.4.0") { because("") } + allowDependency("com.google.android.gms", "play-services-base", "18.0.1") { because("") } + + allowDependency("com.google.android.odml", "image", "1.0.0-beta1") { because("") } // More utility classes // https://developers.google.com/android/reference/com/google/android/gms/common/package-summary allowDependency("com.google.android.gms", "play-services-basement", "17.4.0") { because("") } + allowDependency("com.google.android.gms", "play-services-basement", "18.0.0") { because("") } // https://developers.google.com/android/reference/com/google/android/gms/common/package-summary allowDependency("com.google.android.gms", "play-services-clearcut", "17.0.0") { because("") } @@ -133,12 +137,16 @@ fun Project.configureLicensee() { // Tasks API Android https://developers.google.com/android/guides/tasks allowDependency("com.google.android.gms", "play-services-tasks", "17.2.0") { because("") } + allowDependency("com.google.android.gms", "play-services-tasks", "18.0.1") { because("") } // Barcode Scanning https://developers.google.com/ml-kit/vision/barcode-scanning allowDependency("com.google.mlkit", "barcode-scanning", "16.1.1") { because("") } // MLKit Common https://developers.google.com/ml-kit/vision/barcode-scanning allowDependency("com.google.mlkit", "common", "17.1.1") { because("") } + allowDependency("com.google.mlkit", "common", "18.0.0") { because("") } + + allowDependency("com.google.mlkit", "camera", "16.0.0-beta3") { because("") } // Object Detection https://developers.google.com/ml-kit/vision/object-detection allowDependency("com.google.mlkit", "object-detection", "16.2.3") { because("") } @@ -152,11 +160,14 @@ fun Project.configureLicensee() { // Vision Common // https://developers.google.com/android/reference/com/google/mlkit/vision/common/package-summary allowDependency("com.google.mlkit", "vision-common", "16.3.0") { because("") } + allowDependency("com.google.mlkit", "vision-common", "17.0.0") { because("") } // Vision Common // https://developers.google.com/android/reference/com/google/mlkit/vision/common/package-summary allowDependency("com.google.mlkit", "vision-internal-vkp", "18.0.0") { because("") } + allowDependency("com.google.mlkit", "vision-interfaces", "16.0.0") { because("") } + // Glide allowDependency("com.github.bumptech.glide", "glide", "4.14.2") { because("BSD, part MIT and Apache 2.0. https://github.com/bumptech/glide#license") @@ -176,6 +187,11 @@ fun Project.configureLicensee() { allowDependency("com.github.bumptech.glide", "gifdecoder", "4.14.2") { because("BSD, part MIT and Apache 2.0. https://github.com/bumptech/glide#license") } + + // ICU4C License + allowDependency("com.ibm.icu", "icu4j", "72.1") { + because("BSD, part MIT and Apache 2.0. https://github.com/unicode-org/icu/blob/main/LICENSE") + } } } diff --git a/document/.gitignore b/document/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/document/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/document/build.gradle.kts b/document/build.gradle.kts new file mode 100644 index 0000000000..6001760384 --- /dev/null +++ b/document/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + id(Plugins.BuildPlugins.androidLib) + id(Plugins.BuildPlugins.kotlinAndroid) +} + +android { + namespace = "com.google.android.fhir.document" + compileSdk = Sdk.compileSdk + + defaultConfig { + minSdk = Sdk.minSdk + testInstrumentationRunner = Dependencies.androidJunitRunner + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + // Flag to enable support for the new language APIs + // See https = //developer.android.com/studio/write/java8-support + isCoreLibraryDesugaringEnabled = true + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + packaging { resources.excludes.addAll(listOf("META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt")) } + + sourceSets { getByName("test").apply { resources.setSrcDirs(listOf("test-data")) } } + + kotlin { jvmToolchain(11) } +} + +dependencies { + implementation(Dependencies.Androidx.coreKtx) + implementation(Dependencies.Androidx.appCompat) + implementation(Dependencies.material) + implementation(Dependencies.androidFhirEngine) + implementation(Dependencies.Retrofit.coreRetrofit) + implementation(Dependencies.Retrofit.gsonConverter) + implementation(Dependencies.httpInterceptor) + implementation(Dependencies.zxing) + implementation(Dependencies.nimbus) + implementation(Dependencies.timber) + + coreLibraryDesugaring(Dependencies.desugarJdkLibs) + + testImplementation(Dependencies.junit) + testImplementation(Dependencies.robolectric) + testImplementation(Dependencies.mockitoKotlin) + testImplementation(Dependencies.mockitoInline) + testImplementation(Dependencies.Kotlin.kotlinCoroutinesTest) + testImplementation(Dependencies.mockWebServer) + + androidTestImplementation(Dependencies.AndroidxTest.extJunit) + androidTestImplementation(Dependencies.Espresso.espressoCore) +} diff --git a/document/consumer-rules.pro b/document/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/document/proguard-rules.pro b/document/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/document/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/document/src/main/AndroidManifest.xml b/document/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9d120112b8 --- /dev/null +++ b/document/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/document/src/main/java/com.google.android.fhir.document/IPSDocument.kt b/document/src/main/java/com.google.android.fhir.document/IPSDocument.kt new file mode 100644 index 0000000000..2845b23206 --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/IPSDocument.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2023 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.document + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Resource + +/** + * Represents an International Patient Summary (IPS) document, associating it with a specific + * patient and containing a list of titles present in the document. For detailed specifications, see + * [Official IPS Implementation Guide](https://build.fhir.org/ig/HL7/fhir-ips/index.html). + * + * This class serves as a developer-friendly, in-memory representation of an IPS document, allowing + * for easier manipulation and interaction with its components compared to a raw FHIR Composition + * resource. + * + * @property document The FHIR Bundle itself, which contains the IPS document + * @property titles A list of titles of the sections present in the document. + * @property patient The FHIR Patient resource associated with the IPS document. + */ +data class IPSDocument( + val document: Bundle, + val titles: ArrayList, + val patient: Patient, +) + +/** + * Represents a title, which corresponds to a section present in the IPS document. + * + * @property name The string storing the title of the section itself. Examples: "Allergies and + * Intolerances", "Immunizations", etc... + * @property dataEntries A list of FHIR resources which are present in the section. For example, if + * the title is "Allergies and Intolerances", all the patient's current allergies and/or + * intolerances will be listed here. + */ +data class Title( + val name: String, + val dataEntries: ArrayList<Resource>, +) diff --git a/document/src/main/java/com.google.android.fhir.document/RetrofitSHLService.kt b/document/src/main/java/com.google.android.fhir.document/RetrofitSHLService.kt new file mode 100644 index 0000000000..27449d35b7 --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/RetrofitSHLService.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2023 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.document + +import com.google.android.fhir.NetworkConfiguration +import com.google.android.fhir.sync.remote.GzipUploadInterceptor +import com.google.android.fhir.sync.remote.HttpLogger +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.ResponseBody +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Url + +/* Interface to make HTTP requests to the SHL server */ +interface RetrofitSHLService { + + /* Initial POST request to generate a manifest URL */ + @POST + @Headers("Content-Type: application/json") + suspend fun getManifestUrlAndToken( + @Url path: String, + @Body request: RequestBody, + ): Response<ResponseBody> + + /* POST request to add data to the SHL */ + @POST + @Headers("Content-Type: application/json") + suspend fun postPayload( + @Url path: String, + @Body contentEncrypted: String, + @Header("Authorization") authorization: String, + ): Response<ResponseBody> + + class Builder( + private val baseUrl: String, + private val networkConfiguration: NetworkConfiguration, + ) { + private var httpLoggingInterceptor: HttpLoggingInterceptor? = null + + fun setHttpLogger(httpLogger: HttpLogger) = apply { + httpLoggingInterceptor = httpLogger.toOkHttpLoggingInterceptor() + } + + fun build(): RetrofitSHLService { + val client = + OkHttpClient.Builder() + .connectTimeout(networkConfiguration.connectionTimeOut, TimeUnit.SECONDS) + .readTimeout(networkConfiguration.readTimeOut, TimeUnit.SECONDS) + .writeTimeout(networkConfiguration.writeTimeOut, TimeUnit.SECONDS) + .apply { + if (networkConfiguration.uploadWithGzip) { + addInterceptor(GzipUploadInterceptor) + } + httpLoggingInterceptor?.let { addInterceptor(it) } + } + .build() + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(RetrofitSHLService::class.java) + } + + /* Maybe move these to different class */ + private fun HttpLogger.toOkHttpLoggingInterceptor() = + HttpLoggingInterceptor(log).apply { + level = configuration.level.toOkhttpLogLevel() + configuration.headersToIgnore?.forEach { this.redactHeader(it) } + } + + private fun HttpLogger.Level.toOkhttpLogLevel() = + when (this) { + HttpLogger.Level.NONE -> HttpLoggingInterceptor.Level.NONE + HttpLogger.Level.BASIC -> HttpLoggingInterceptor.Level.BASIC + HttpLogger.Level.HEADERS -> HttpLoggingInterceptor.Level.HEADERS + HttpLogger.Level.BODY -> HttpLoggingInterceptor.Level.BODY + } + } + + companion object { + fun builder(baseUrl: String, networkConfiguration: NetworkConfiguration) = + Builder(baseUrl, networkConfiguration) + } +} diff --git a/document/src/main/java/com.google.android.fhir.document/generate/EncryptionUtils.kt b/document/src/main/java/com.google.android.fhir.document/generate/EncryptionUtils.kt new file mode 100644 index 0000000000..52e6ecfcbe --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/generate/EncryptionUtils.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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.document.generate + +import android.util.Base64 +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.JWEObject +import com.nimbusds.jose.Payload +import com.nimbusds.jose.crypto.DirectEncrypter +import java.security.SecureRandom + +object EncryptionUtils { + + /* Encrypt a given string as a JWE token, using a given key */ + fun encrypt(data: String, key: String): String { + val header = JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A256GCM) + val jweObj = JWEObject(header, Payload(data)) + val decodedKey = Base64.decode(key, Base64.URL_SAFE) + val encrypter = DirectEncrypter(decodedKey) + jweObj.encrypt(encrypter) + return jweObj.serialize() + } + + /* Generate a random SHL-specific key */ + fun generateRandomKey(): String { + val random = SecureRandom() + val shlKeySize = 32 + val keyBytes = ByteArray(shlKeySize) + random.nextBytes(keyBytes) + return Base64.encodeToString(keyBytes, Base64.URL_SAFE) + } +} diff --git a/document/src/main/java/com.google.android.fhir.document/generate/QRGenerator.kt b/document/src/main/java/com.google.android.fhir.document/generate/QRGenerator.kt new file mode 100644 index 0000000000..a5345bcf9e --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/generate/QRGenerator.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 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.document.generate + +import android.widget.ImageView + +/* The QR Generator interface handles the generation of SHL QR codes */ +interface QRGenerator { + + /* Generate and display the SHL QR code */ + fun generateAndSetQRCode(shLink: String, qrView: ImageView) +} diff --git a/document/src/main/java/com.google.android.fhir.document/generate/QRGeneratorImpl.kt b/document/src/main/java/com.google.android.fhir.document/generate/QRGeneratorImpl.kt new file mode 100644 index 0000000000..3823397dab --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/generate/QRGeneratorImpl.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2023 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.document.generate + +import android.graphics.Bitmap +import android.widget.ImageView + +internal class QRGeneratorImpl(private val qrGeneratorUtils: QRGeneratorUtils) : QRGenerator { + + /* Generate and display the SHL QR code */ + override fun generateAndSetQRCode(shLink: String, qrView: ImageView) { + val qrCodeBitmap = generateQRCode(shLink) + qrView.setImageBitmap(qrCodeBitmap) + } + + /* Generates the SHL QR code for the given payload */ + internal fun generateQRCode(content: String): Bitmap { + val qrCodeBitmap = qrGeneratorUtils.createQRCodeBitmap(content) + val logoBitmap = qrGeneratorUtils.createLogoBitmap(qrCodeBitmap) + return qrGeneratorUtils.overlayLogoOnQRCode(qrCodeBitmap, logoBitmap) + } +} diff --git a/document/src/main/java/com.google.android.fhir.document/generate/QRGeneratorUtils.kt b/document/src/main/java/com.google.android.fhir.document/generate/QRGeneratorUtils.kt new file mode 100644 index 0000000000..5d98e46a92 --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/generate/QRGeneratorUtils.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2023 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.document.generate + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.google.android.fhir.document.R +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter + +internal class QRGeneratorUtils(private val context: Context) { + + /* Creates a QR for a given string */ + fun createQRCodeBitmap(content: String): Bitmap { + val hints = mutableMapOf<EncodeHintType, Any>() + hints[EncodeHintType.MARGIN] = 2 + val qrCodeWriter = QRCodeWriter() + val bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, 512, 512, hints) + val width = bitMatrix.width + val height = bitMatrix.height + val qrCodeBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + + for (x in 0 until width) { + for (y in 0 until height) { + qrCodeBitmap.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE) + } + } + return qrCodeBitmap + } + + /* Creates a bitmap containing the SMART logo */ + fun createLogoBitmap(qrCodeBitmap: Bitmap): Bitmap { + val logoScale = 0.4 + val logoDrawable = ContextCompat.getDrawable(context, R.drawable.smart_logo) + val logoAspectRatio = + logoDrawable!!.intrinsicWidth.toFloat() / logoDrawable.intrinsicHeight.toFloat() + val width = qrCodeBitmap.width + val logoWidth = (width * logoScale).toInt() + val logoHeight = (logoWidth / logoAspectRatio).toInt() + + return convertDrawableToBitmap(logoDrawable, logoWidth, logoHeight) + } + + /* Overlays the SMART logo in the centre of a QR code */ + fun overlayLogoOnQRCode(qrCodeBitmap: Bitmap, logoBitmap: Bitmap): Bitmap { + val centerX = (qrCodeBitmap.width - logoBitmap.width) / 2 + val centerY = (qrCodeBitmap.height - logoBitmap.height) / 2 + + val backgroundBitmap = + Bitmap.createBitmap(logoBitmap.width, logoBitmap.height, Bitmap.Config.RGB_565) + backgroundBitmap.eraseColor(Color.WHITE) + + val canvas = Canvas(backgroundBitmap) + canvas.drawBitmap(logoBitmap, 0f, 0f, null) + + val finalBitmap = Bitmap.createBitmap(qrCodeBitmap) + val finalCanvas = Canvas(finalBitmap) + finalCanvas.drawBitmap(backgroundBitmap, centerX.toFloat(), centerY.toFloat(), null) + + return finalBitmap + } + + /* Converts the logo into a bitmap */ + private fun convertDrawableToBitmap(drawable: Drawable, width: Int, height: Int): Bitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } +} diff --git a/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGenerationData.kt b/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGenerationData.kt new file mode 100644 index 0000000000..8a09e1d106 --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGenerationData.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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.document.generate + +import com.google.android.fhir.document.IPSDocument +import java.time.Instant + +/** + * Represents a SHL data structure, which stores information needed to generate a SHL. + * + * SHLs, or Smart Health Links, are a standardized format for securely sharing health-related + * information. For official documentation and specifications, see + * [SHL Documentation](https://docs.smarthealthit.org/smart-health-links/). + * + * @property label A label describing the SHL data. + * @property expirationTime The expiration time of the SHL data, if any. + * @property ipsDoc The IPS document linked to by the SHL. + */ +data class SHLinkGenerationData( + val label: String, + val expirationTime: Instant?, + val ipsDoc: IPSDocument, +) diff --git a/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGenerator.kt b/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGenerator.kt new file mode 100644 index 0000000000..61d151fba9 --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGenerator.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 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.document.generate + +/* The SHLink Generator interface handles the generation of Smart Health Links */ +interface SHLinkGenerator { + + /* Returns the newly generated SHLink */ + suspend fun generateSHLink( + shLinkGenerationData: SHLinkGenerationData, + passcode: String, + serverBaseUrl: String, + optionalViewer: String, + ): String +} diff --git a/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGeneratorImpl.kt b/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGeneratorImpl.kt new file mode 100644 index 0000000000..998ed9e5de --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/generate/SHLinkGeneratorImpl.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2023 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.document.generate + +import android.content.ContentValues.TAG +import android.util.Base64 +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import com.google.android.fhir.document.RetrofitSHLService +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import timber.log.Timber + +internal class SHLinkGeneratorImpl( + private val apiService: RetrofitSHLService, + private val encryptionUtility: EncryptionUtils, +) : SHLinkGenerator { + + private val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + + /* Generate an SHL */ + override suspend fun generateSHLink( + shLinkGenerationData: SHLinkGenerationData, + passcode: String, + serverBaseUrl: String, + optionalViewer: String, + ): String { + val initialPostResponse = getManifestUrlAndToken(passcode) + return generateAndPostPayload( + initialPostResponse, + shLinkGenerationData, + passcode, + serverBaseUrl, + "$optionalViewer#", + ) + } + + /* Send a POST request to the SHL server to get a new manifest URL. + Can optionally add a passcode to the SHL here */ + private suspend fun getManifestUrlAndToken(passcode: String): JSONObject { + val requestBody = + if (passcode.isNotBlank()) { + "{\"passcode\": \"$passcode\"}".toRequestBody("application/json".toMediaTypeOrNull()) + } else { + "{}".toRequestBody("application/json".toMediaTypeOrNull()) + } + val response = apiService.getManifestUrlAndToken("", requestBody) + return if (response.isSuccessful) { + val responseBody = response.body()?.string() + if (!responseBody.isNullOrBlank()) { + JSONObject(responseBody) + } else { + Timber.e("Empty response body") + JSONObject() + } + } else { + Timber.e("HTTP Error: ${response.code()}") + JSONObject() + } + } + + /* POST the data to the SHL server and return the link itself */ + private suspend fun generateAndPostPayload( + initialPostResponse: JSONObject, + shLinkGenerationData: SHLinkGenerationData, + passcode: String, + serverBaseUrl: String, + optionalViewer: String, + ): String { + val manifestToken = initialPostResponse.getString("id") + val manifestUrl = "$serverBaseUrl/api/shl/$manifestToken" + val managementToken = initialPostResponse.getString("managementToken") + val exp = shLinkGenerationData.expirationTime?.epochSecond?.toString() ?: "" + val key = encryptionUtility.generateRandomKey() + val shLinkPayload = + constructSHLinkPayload( + manifestUrl, + shLinkGenerationData.label, + getKeyFlags(passcode), + key, + exp, + ) + val data: String = parser.encodeResourceToString(shLinkGenerationData.ipsDoc.document) + postPayload(data, manifestToken, key, managementToken) + val encodedPayload = base64UrlEncode(shLinkPayload) + return "${optionalViewer}shlink:/$encodedPayload" + } + + /* Constructs the SHL payload */ + private fun constructSHLinkPayload( + manifestUrl: String, + label: String?, + flags: String?, + key: String, + exp: String?, + ): String { + val payloadObject = + JSONObject() + .apply { + put("url", manifestUrl) + put("key", key) + flags?.let { put("flag", it) } + label?.takeIf { it.isNotEmpty() }?.let { put("label", it) } + exp?.takeIf { it.isNotEmpty() }?.let { put("exp", it) } + } + .toString() + return payloadObject + } + + /* Base64Url encodes a given string */ + private fun base64UrlEncode(data: String): String { + return Base64.encodeToString(data.toByteArray(), Base64.URL_SAFE) + } + + /* Sets the P flag if a passcode has been set */ + private fun getKeyFlags(passcode: String): String { + return if (passcode.isNotEmpty()) "P" else "" + } + + /* POST the IPS document to the manifest URL */ + private suspend fun postPayload( + file: String, + manifestToken: String, + key: String, + managementToken: String, + ) { + try { + val contentEncrypted = encryptionUtility.encrypt(file, key) + val authorization = "Bearer $managementToken" + val response = apiService.postPayload(manifestToken, contentEncrypted, authorization) + if (!response.isSuccessful) { + Timber.e("HTTP Error: ${response.code()}") + } + } catch (e: Exception) { + Timber.e(TAG, "Error while posting payload: ${e.message}", e) + throw e + } + } +} diff --git a/document/src/main/res/drawable/smart_logo.xml b/document/src/main/res/drawable/smart_logo.xml new file mode 100644 index 0000000000..c612f50581 --- /dev/null +++ b/document/src/main/res/drawable/smart_logo.xml @@ -0,0 +1,55 @@ +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + android:width="186dp" + android:height="40dp" + android:viewportWidth="186" + android:viewportHeight="40" +> + <group> + <clip-path android:pathData="M0,0h186v40h-186z" /> + <path + android:pathData="M12.93,0H18.2L24.42,10.12L30.74,0H35.9L24.42,18.65L12.93,0Z" + android:fillColor="#722772" + /> + <path + android:pathData="M0,19.64L2.66,15.46H15.09L8.93,5.23L11.49,0.99L22.92,19.64H0Z" + android:fillColor="#E24A31" + /> + <path + android:pathData="M48.89,21.29L46.28,25.47H33.85L40.01,35.82L37.4,40L25.97,21.29H48.89Z" + android:fillColor="#89BF44" + /> + <path + android:pathData="M37.4,0.94L40.06,5.23L33.79,15.46H46.33L48.89,19.64H25.97L37.4,0.94Z" + android:fillColor="#E77D26" + /> + <path + android:pathData="M11.49,40L8.82,35.71L15.09,25.47H2.55L0,21.29H22.92L11.49,40Z" + android:fillColor="#F1B42A" + /> + <path + android:pathData="M74.58,16.14H64.2V19.88H73.69C75.08,19.88 76.02,20.1 76.52,20.59C77.02,21.09 77.3,21.97 77.3,23.29V25C77.3,26.32 77.08,27.2 76.58,27.69C76.08,28.19 75.13,28.41 73.75,28.41H64.7C63.31,28.41 62.32,28.19 61.82,27.69C61.32,27.2 61.04,26.32 61.04,25V24.12H63.59V26.26H74.74V22.25H65.26C63.87,22.25 62.93,22.02 62.43,21.53C61.93,21.03 61.65,20.15 61.7,18.83V17.46C61.7,16.14 61.98,15.26 62.48,14.76C62.98,14.27 63.92,14.05 65.31,14.05H73.58C74.97,14.05 75.91,14.27 76.41,14.76C76.91,15.26 77.19,16.03 77.19,17.18V18.06H74.63V16.14H74.58Z" + android:fillColor="#676868" + /> + <path + android:pathData="M86.01,16.63C86.01,16.85 86.01,17.13 86.06,17.51C86.06,18.12 86.12,18.5 86.12,18.67V28.35H83.79V13.99H86.12L93.44,23.29L100.21,13.99H102.82V28.35H100.21V18.67C100.21,18.34 100.21,17.95 100.27,17.62C100.32,17.29 100.32,16.96 100.38,16.69C100.21,17.07 100.05,17.51 99.88,17.9C99.77,18.17 99.6,18.45 99.38,18.67L93.72,26.1H93L87.06,18.72C86.84,18.45 86.67,18.12 86.51,17.84C86.34,17.46 86.18,17.07 86.01,16.63Z" + android:fillColor="#676868" + /> + <path + android:pathData="M135.84,28.46V14.1H147.77C148.6,14.05 149.49,14.27 150.16,14.76C150.71,15.31 150.93,16.08 150.88,16.91V20.1C150.93,20.87 150.71,21.64 150.16,22.19C149.49,22.69 148.6,22.91 147.77,22.85H145.83L152.38,28.46H148.66L142.78,22.85H138.73V28.46H135.84ZM146.49,16.25H138.61V20.87H146.49C146.94,20.92 147.38,20.81 147.77,20.59C148.05,20.26 148.16,19.82 148.1,19.44V17.73C148.16,17.35 147.99,16.96 147.77,16.63C147.38,16.36 146.94,16.19 146.49,16.25Z" + android:fillColor="#676868" + /> + <path + android:pathData="M165.08,16.25V28.46H162.31V16.25H155.32V14.16H172.13V16.25H165.08Z" + android:fillColor="#676868" + /> + <path + android:pathData="M130.68,28.46H125.35L119.14,18.23L112.7,28.46H107.54L119.14,9.65L130.68,28.46Z" + android:fillColor="#64AED0" + /> + <path + android:pathData="M175.68,13.44C175.96,12.84 176.34,12.29 176.84,11.85C177.34,11.41 177.9,11.02 178.56,10.8C179.9,10.31 181.39,10.31 182.67,10.8C183.28,11.02 183.89,11.41 184.39,11.85C184.89,12.29 185.28,12.84 185.55,13.44C185.83,14.05 186,14.76 186,15.42C186,16.14 185.83,16.8 185.55,17.4C185.28,18.01 184.89,18.56 184.39,19C183.89,19.44 183.34,19.82 182.67,20.04C181.34,20.54 179.84,20.54 178.56,20.04C177.95,19.82 177.34,19.44 176.84,19C176.34,18.56 175.96,18.01 175.68,17.4C175.4,16.8 175.23,16.08 175.23,15.42C175.23,14.76 175.4,14.05 175.68,13.44ZM176.57,17.13C176.79,17.62 177.12,18.12 177.51,18.5C177.9,18.89 178.4,19.22 178.9,19.38C179.45,19.6 180.06,19.71 180.62,19.71C181.23,19.71 181.78,19.6 182.34,19.38C182.84,19.16 183.34,18.89 183.72,18.5C184.11,18.12 184.45,17.62 184.67,17.13C184.89,16.58 185,16.03 185,15.42C185,14.87 184.89,14.27 184.67,13.77C184.45,13.28 184.11,12.78 183.72,12.45C183.34,12.07 182.84,11.74 182.34,11.57C181.78,11.35 181.23,11.24 180.62,11.24C180.01,11.24 179.45,11.35 178.9,11.57C178.4,11.79 177.9,12.07 177.51,12.45C177.12,12.84 176.79,13.28 176.57,13.77C176.34,14.27 176.23,14.87 176.23,15.42C176.18,16.03 176.29,16.58 176.57,17.13ZM180.95,12.51C181.56,12.45 182.12,12.62 182.61,12.95C183,13.28 183.23,13.72 183.17,14.21C183.23,14.65 183.06,15.09 182.67,15.37C182.34,15.64 181.89,15.81 181.45,15.81L183.23,18.39H182.17L180.45,15.86H179.4V18.39H178.4V12.51H180.95ZM180.51,15.09C180.73,15.09 180.95,15.09 181.12,15.09C181.28,15.09 181.45,15.04 181.62,14.98C181.78,14.93 181.89,14.82 181.95,14.65C182.06,14.49 182.12,14.32 182.06,14.1C182.06,13.94 182,13.77 181.95,13.61C181.89,13.5 181.78,13.39 181.62,13.33C181.51,13.28 181.34,13.22 181.17,13.22C181.01,13.22 180.84,13.17 180.67,13.17H179.4V15.04H180.51V15.09Z" + android:fillColor="#676868" + /> + </group> +</vector> diff --git a/document/src/test/java/com/google/android/fhir/document/EncryptionUtilsTest.kt b/document/src/test/java/com/google/android/fhir/document/EncryptionUtilsTest.kt new file mode 100644 index 0000000000..ff9170931b --- /dev/null +++ b/document/src/test/java/com/google/android/fhir/document/EncryptionUtilsTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 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.document + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import com.google.android.fhir.document.generate.EncryptionUtils +import com.google.android.fhir.testing.readFromFile +import com.nimbusds.jose.shaded.gson.Gson +import org.hl7.fhir.r4.model.Bundle +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EncryptionUtilsTest { + + private val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + private val minimalBundleString = + parser.encodeResourceToString(readFromFile(Bundle::class.java, "/bundleMinimal.json")) + + @Test + fun randomKeysCanBeGenerated() { + val key = EncryptionUtils.generateRandomKey() + val base64UrlEncodedKeyLength = 45 + assert(key.length == base64UrlEncodedKeyLength) + } + + @Test + fun canConvertFilesIntoJweTokens() { + val encryptionKey = EncryptionUtils.generateRandomKey() + val contentJson = Gson().toJson(minimalBundleString) + val contentEncrypted = EncryptionUtils.encrypt(contentJson, encryptionKey) + Assert.assertEquals(contentEncrypted.split('.').size, 5) + } +} diff --git a/document/src/test/java/com/google/android/fhir/document/QRGeneratorImplTest.kt b/document/src/test/java/com/google/android/fhir/document/QRGeneratorImplTest.kt new file mode 100644 index 0000000000..b0b6bfc26e --- /dev/null +++ b/document/src/test/java/com/google/android/fhir/document/QRGeneratorImplTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2023 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.document + +import android.graphics.Bitmap +import android.widget.ImageView +import com.google.android.fhir.document.generate.QRGeneratorImpl +import com.google.android.fhir.document.generate.QRGeneratorUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations + +class QRGeneratorImplTest { + + @Mock private lateinit var qrGeneratorUtils: QRGeneratorUtils + + @Mock private lateinit var qrView: ImageView + + private lateinit var qrGeneratorImpl: QRGeneratorImpl + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + qrGeneratorImpl = QRGeneratorImpl(qrGeneratorUtils) + } + + @Test + fun testGenerateAndSetQRCode() { + val shLink = "SHLink" + val qrCodeBitmap = mock(Bitmap::class.java) + + `when`(qrGeneratorUtils.createQRCodeBitmap(shLink)).thenReturn(qrCodeBitmap) + `when`(qrGeneratorUtils.createLogoBitmap(qrCodeBitmap)).thenReturn(qrCodeBitmap) + `when`( + qrGeneratorUtils.overlayLogoOnQRCode( + qrCodeBitmap, + qrCodeBitmap, + ), + ) + .thenReturn(qrCodeBitmap) + + qrGeneratorImpl.generateAndSetQRCode(shLink, qrView) + verify(qrView).setImageBitmap(qrCodeBitmap) + } + + @Test + fun testGenerateQRCode() { + val content = "content" + val qrCodeBitmap = mock(Bitmap::class.java) + `when`(qrGeneratorUtils.createQRCodeBitmap(content)).thenReturn(qrCodeBitmap) + `when`(qrGeneratorUtils.createLogoBitmap(qrCodeBitmap)).thenReturn(qrCodeBitmap) + `when`( + qrGeneratorUtils.overlayLogoOnQRCode( + qrCodeBitmap, + qrCodeBitmap, + ), + ) + .thenReturn(qrCodeBitmap) + val result = qrGeneratorImpl.generateQRCode(content) + assertEquals(qrCodeBitmap, result) + } + + @Test + fun testGenerateQRCodeWithNullBitmap() { + val content = "content" + `when`(qrGeneratorUtils.createQRCodeBitmap(content)).thenReturn(null) + val result = qrGeneratorImpl.generateQRCode(content) + assertNull(result) + } +} diff --git a/document/src/test/java/com/google/android/fhir/document/SHLinkGeneratorImplTest.kt b/document/src/test/java/com/google/android/fhir/document/SHLinkGeneratorImplTest.kt new file mode 100644 index 0000000000..b6b085dca5 --- /dev/null +++ b/document/src/test/java/com/google/android/fhir/document/SHLinkGeneratorImplTest.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2023 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.document + +import android.util.Base64 +import com.google.android.fhir.NetworkConfiguration +import com.google.android.fhir.document.generate.EncryptionUtils +import com.google.android.fhir.document.generate.SHLinkGenerationData +import com.google.android.fhir.document.generate.SHLinkGeneratorImpl +import java.time.Instant +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.hl7.fhir.r4.model.Bundle +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SHLinkGeneratorImplTest { + + @Mock private lateinit var encryptionUtility: EncryptionUtils + private lateinit var shLinkGeneratorImpl: SHLinkGeneratorImpl + + private val mockWebServer = MockWebServer() + private val baseUrl = "/shl/" + + private val apiService by lazy { + RetrofitSHLService.Builder(mockWebServer.url(baseUrl).toString(), NetworkConfiguration()) + .build() + } + + private val mockIPSDocument = mock(IPSDocument::class.java) + private val initialPostResponse = + JSONObject().apply { + put("id", "123") + put("managementToken", "token123") + } + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + shLinkGeneratorImpl = SHLinkGeneratorImpl(apiService, encryptionUtility) + `when`(mockIPSDocument.document).thenReturn(Bundle()) + `when`( + encryptionUtility.encrypt( + Mockito.anyString(), + Mockito.anyString(), + ), + ) + .thenReturn("something") + `when`(encryptionUtility.generateRandomKey()).thenReturn("key") + + // Mock response for getManifestUrlAndToken + val mockResponse = MockResponse().setResponseCode(200).setBody(initialPostResponse.toString()) + mockWebServer.enqueue(mockResponse) + + // Mock response for postPayload + val postPayloadResponse = + MockResponse().setResponseCode(200).setBody("{'test key': 'test value'}") + mockWebServer.enqueue(postPayloadResponse) + } + + private suspend fun assertCommonFunctionality( + shLinkGenerationData: SHLinkGenerationData, + passcode: String, + optionalViewer: String, + expectedExp: String? = null, + expectedFlag: String? = null, + expectedLabel: String? = null, + ) { + val result = + shLinkGeneratorImpl.generateSHLink( + shLinkGenerationData, + passcode, + "", + optionalViewer, + ) + + val recordedRequestGetManifest: RecordedRequest = mockWebServer.takeRequest() + assertEquals("/shl/", recordedRequestGetManifest.path) + val recordedRequestPostPayload: RecordedRequest = mockWebServer.takeRequest() + assertEquals("/shl/123", recordedRequestPostPayload.path) + + val parts = result.split("#shlink:/") + assertTrue(parts.size == 2) + + val decodedJSON = + with(parts[1]) { + val decodedString = String(Base64.decode(this, Base64.URL_SAFE), Charsets.UTF_8) + JSONObject(decodedString) + } + + expectedExp?.let { + assertTrue(decodedJSON.has("exp")) + assertEquals(decodedJSON.get("exp"), it) + } + ?: assertFalse(decodedJSON.has("exp")) + + expectedFlag?.let { + assertTrue(decodedJSON.has("flag")) + assertEquals(decodedJSON.get("flag").toString(), expectedFlag) + } + ?: { + assertTrue(decodedJSON.has("flag")) + assertFalse(decodedJSON.get("flag").toString().contains("P")) + } + + expectedLabel?.let { + assertTrue(decodedJSON.has("label")) + assertEquals(decodedJSON.get("label"), expectedLabel) + } + ?: assertFalse(decodedJSON.has("label")) + + if (optionalViewer.isNotEmpty()) { + assertTrue(result.contains("$optionalViewer#shlink:/")) + } + } + + @Test + fun testGenerateSHLinkWithExp() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("", Instant.parse("2023-11-01T00:00:00.00Z"), mockIPSDocument), + "", + "", + "1698796800", + null, + null, + ) + } + + @Test + fun testGenerateSHLinkWithoutExp() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("", null, mockIPSDocument), + "", + "", + null, + null, + null, + ) + } + + @Test + fun testGenerateSHLinkWithPasscode() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("", null, mockIPSDocument), + "passcode", + "", + null, + "P", + null, + ) + } + + @Test + fun testGenerateSHLinkWithoutPasscode() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("", null, mockIPSDocument), + "", + "", + null, + "", + null, + ) + } + + @Test + fun testGenerateSHLinkWithLabel() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("label", null, mockIPSDocument), + "", + "", + null, + null, + "label", + ) + } + + @Test + fun testGenerateSHLinkWithoutLabel() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("", null, mockIPSDocument), + "", + "", + null, + null, + null, + ) + } + + @Test + fun testGenerateSHLinkWithOptionalViewer() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("", null, mockIPSDocument), + "", + "viewerUrl", + null, + null, + null, + ) + } + + @Test + fun testGenerateSHLinkWithoutOptionalViewer() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("", null, mockIPSDocument), + "", + "", + null, + null, + null, + ) + } + + @Test + fun testGenerateSHLinkWithAllFeatures() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("label", Instant.parse("2023-11-01T00:00:00.00Z"), mockIPSDocument), + "passcode", + "viewerUrl", + "1698796800", + "P", + "label", + ) + } + + @Test + fun testGenerateSHLinkWithNoFeatures() = runTest { + assertCommonFunctionality( + SHLinkGenerationData("", null, mockIPSDocument), + "", + "", + null, + null, + null, + ) + } +} diff --git a/document/test-data/bundleMinimal.json b/document/test-data/bundleMinimal.json new file mode 100644 index 0000000000..806bed6da8 --- /dev/null +++ b/document/test-data/bundleMinimal.json @@ -0,0 +1,443 @@ +{ + "resourceType" : "Bundle", + "id" : "bundle-minimal", + "language" : "en-US", + "identifier" : { + "system" : "urn:oid:2.16.724.4.8.10.200.10", + "value" : "28b95815-76ce-457b-b7ae-a972e527db40" + }, + "type" : "document", + "timestamp" : "2020-12-11T14:30:00+01:00", + "entry" : [{ + "fullUrl" : "urn:uuid:6e1fb74a-742b-4c7b-8487-171dacb88766", + "resource" : { + "resourceType" : "Composition", + "id" : "6e1fb74a-742b-4c7b-8487-171dacb88766", + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Generated Narrative</b></p><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\">Resource \"6e1fb74a-742b-4c7b-8487-171dacb88766\" </p></div><p><b>status</b>: final</p><p><b>type</b>: Patient summary Document <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"https://loinc.org/\">LOINC</a>#60591-5)</span></p><p><b>date</b>: 2020-12-11 02:30:00+0100</p><p><b>author</b>: Beetje van Hulp, MD </p><p><b>title</b>: Patient Summary as of December 11, 2020 14:30</p><p><b>confidentiality</b>: N</p><blockquote><p><b>attester</b></p><p><b>mode</b>: legal</p><p><b>time</b>: 2020-12-11 02:30:00+0100</p><p><b>party</b>: Beetje van Hulp, MD </p></blockquote><blockquote><p><b>attester</b></p><p><b>mode</b>: legal</p><p><b>time</b>: 2020-12-11 02:30:00+0100</p><p><b>party</b>: Anorg Aniza Tion BV </p></blockquote><p><b>custodian</b>: Anorg Aniza Tion BV</p><h3>RelatesTos</h3><table class=\"grid\"><tr><td>-</td><td><b>Code</b></td><td><b>Target[x]</b></td></tr><tr><td>*</td><td>appends</td><td>id: 20e12ce3-857f-49c0-b888-cb670597f191</td></tr></table><h3>Events</h3><table class=\"grid\"><tr><td>-</td><td><b>Code</b></td><td><b>Period</b></td></tr><tr><td>*</td><td>care provision <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"http://terminology.hl7.org/3.1.0/CodeSystem-v3-ActClass.html\">ActClass</a>#PCPR)</span></td><td>?? --> 2020-12-11 02:30:00+0100</td></tr></table></div>" + }, + "status" : "final", + "type" : { + "coding" : [{ + "system" : "http://loinc.org", + "code" : "60591-5", + "display" : "Patient summary Document" + }] + }, + "subject" : { + "reference" : "Patient/7685713c-e29e-4a75-8a90-45be7ba3be94" + }, + "date" : "2020-12-11T14:30:00+01:00", + "author" : [{ + "reference" : "Practitioner/98315ba9-ffea-41ef-b59b-a836c039858f" + }], + "title" : "Patient Summary as of December 11, 2020 14:30", + "confidentiality" : "N", + "attester" : [{ + "mode" : "legal", + "time" : "2020-12-11T14:30:00+01:00", + "party" : { + "reference" : "Practitioner/98315ba9-ffea-41ef-b59b-a836c039858f" + } + }, + { + "mode" : "legal", + "time" : "2020-12-11T14:30:00+01:00", + "party" : { + "reference" : "Organization/bb6bdf4f-7fcb-4d44-96a5-b858ad031d1d" + } + }], + "custodian" : { + "reference" : "Organization/bb6bdf4f-7fcb-4d44-96a5-b858ad031d1d" + }, + "relatesTo" : [{ + "code" : "appends", + "targetIdentifier" : { + "system" : "urn:oid:2.16.724.4.8.10.200.10", + "value" : "20e12ce3-857f-49c0-b888-cb670597f191" + } + }], + "event" : [{ + "code" : [{ + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/v3-ActClass", + "code" : "PCPR" + }] + }], + "period" : { + "end" : "2020-12-11T14:30:00+01:00" + } + }], + "section" : [{ + "title" : "Active Problems", + "code" : { + "coding" : [{ + "system" : "http://loinc.org", + "code" : "11450-4", + "display" : "Problem list Reported" + }] + }, + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><ul><li><div><b>Condition Name</b>: Menopausal Flushing</div><div><b>Code</b>: <span>198436008</span></div><div><b>Status</b>: <span>Active</span></div></li></ul></div>" + }, + "entry" : [{ + "reference" : "Condition/ad84b7a2-b4dd-474e-bef3-0779e6cb595f" + }] + }, + { + "title" : "Medication", + "code" : { + "coding" : [{ + "system" : "http://loinc.org", + "code" : "10160-0", + "display" : "History of Medication use Narrative" + }] + }, + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><ul><li><div><b>Medication Name</b>: Oral anastrozole 1mg tablet</div><div><b>Code</b>: <span></span></div><div><b>Status</b>: <span>Active, started March 2015</span></div><div>Instructions: Take 1 time per day</div></li></ul></div>" + }, + "entry" : [{ + "reference" : "MedicationStatement/6e883e5e-7648-485a-86de-3640a61601fe" + }] + }, + { + "title" : "Allergies and Intolerances", + "code" : { + "coding" : [{ + "system" : "http://loinc.org", + "code" : "48765-2", + "display" : "Allergies and adverse reactions Document" + }] + }, + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><ul><li><div><b>Allergy Name</b>: Pencillins</div><div><b>Verification Status</b>: Confirmed</div><div><b>Reaction</b>: <span>no information</span></div></li></ul></div>" + }, + "entry" : [{ + "reference" : "AllergyIntolerance/fe2769fd-22c9-4307-9122-ee0466e5aebb" + }] + }] + } + }, + { + "fullUrl" : "urn:uuid:7685713c-e29e-4a75-8a90-45be7ba3be94", + "resource" : { + "resourceType" : "Patient", + "id" : "7685713c-e29e-4a75-8a90-45be7ba3be94", + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Generated Narrative: Patient</b><a name=\"7685713c-e29e-4a75-8a90-45be7ba3be94\"> </a></p><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\">Resource Patient "7685713c-e29e-4a75-8a90-45be7ba3be94" </p></div><p><b>identifier</b>: id: 574687583</p><p><b>active</b>: true</p><p><b>name</b>: Martha DeLarosa </p><p><b>telecom</b>: <a href=\"tel:+31788700800\">+31788700800</a></p><p><b>gender</b>: female</p><p><b>birthDate</b>: 1972-05-01</p><p><b>address</b>: Laan Van Europa 1600 Dordrecht 3317 DB NL </p><h3>Contacts</h3><table class=\"grid\"><tr><td>-</td><td><b>Relationship</b></td><td><b>Name</b></td><td><b>Telecom</b></td><td><b>Address</b></td></tr><tr><td>*</td><td>mother <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"http://terminology.hl7.org/5.0.0/CodeSystem-v3-RoleCode.html\">RoleCode</a>#MTH)</span></td><td>Martha Mum </td><td><a href=\"tel:+33-555-20036\">+33-555-20036</a></td><td>Promenade des Anglais 111 Lyon 69001 FR </td></tr></table></div>" + }, + "identifier" : [{ + "system" : "urn:oid:2.16.840.1.113883.2.4.6.3", + "value" : "574687583" + }], + "active" : true, + "name" : [{ + "family" : "DeLarosa", + "given" : ["Martha"] + }], + "telecom" : [{ + "system" : "phone", + "value" : "+31788700800", + "use" : "home" + }], + "gender" : "female", + "birthDate" : "1972-05-01", + "address" : [{ + "line" : ["Laan Van Europa 1600"], + "city" : "Dordrecht", + "postalCode" : "3317 DB", + "country" : "NL" + }], + "contact" : [{ + "relationship" : [{ + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code" : "MTH" + }] + }], + "name" : { + "family" : "Mum", + "given" : ["Martha"] + }, + "telecom" : [{ + "system" : "phone", + "value" : "+33-555-20036", + "use" : "home" + }], + "address" : { + "line" : ["Promenade des Anglais 111"], + "city" : "Lyon", + "postalCode" : "69001", + "country" : "FR" + } + }] + } + }, + { + "fullUrl" : "urn:uuid:98315ba9-ffea-41ef-b59b-a836c039858f", + "resource" : { + "resourceType" : "Practitioner", + "id" : "98315ba9-ffea-41ef-b59b-a836c039858f", + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Generated Narrative: Practitioner</b><a name=\"98315ba9-ffea-41ef-b59b-a836c039858f\"> </a></p><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\">Resource Practitioner "98315ba9-ffea-41ef-b59b-a836c039858f" </p></div><p><b>identifier</b>: id: 129854633</p><p><b>active</b>: true</p><p><b>name</b>: Beetje van Hulp </p><h3>Qualifications</h3><table class=\"grid\"><tr><td>-</td><td><b>Code</b></td></tr><tr><td>*</td><td>Doctor of Medicine <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (degreeLicenseCertificate[2.7]#MD)</span></td></tr></table></div>" + }, + "identifier" : [{ + "system" : "urn:oid:2.16.528.1.1007.3.1", + "value" : "129854633", + "assigner" : { + "display" : "CIBG" + } + }], + "active" : true, + "name" : [{ + "family" : "van Hulp", + "given" : ["Beetje"] + }], + "qualification" : [{ + "code" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/v2-0360", + "version" : "2.7", + "code" : "MD", + "display" : "Doctor of Medicine" + }] + } + }] + } + }, + { + "fullUrl" : "urn:uuid:bb6bdf4f-7fcb-4d44-96a5-b858ad031d1d", + "resource" : { + "resourceType" : "Organization", + "id" : "bb6bdf4f-7fcb-4d44-96a5-b858ad031d1d", + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Generated Narrative: Organization</b><a name=\"bb6bdf4f-7fcb-4d44-96a5-b858ad031d1d\"> </a></p><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\">Resource Organization "bb6bdf4f-7fcb-4d44-96a5-b858ad031d1d" </p></div><p><b>identifier</b>: id: 564738757</p><p><b>active</b>: true</p><p><b>name</b>: Anorg Aniza Tion BV / The best custodian ever</p><p><b>telecom</b>: <a href=\"tel:+31-51-34343400\">+31-51-34343400</a></p><p><b>address</b>: Houttuinen 27 Dordrecht 3311 CE NL (WORK)</p></div>" + }, + "identifier" : [{ + "system" : "urn:oid:2.16.528.1.1007.3.3", + "value" : "564738757" + }], + "active" : true, + "name" : "Anorg Aniza Tion BV / The best custodian ever", + "telecom" : [{ + "system" : "phone", + "value" : "+31-51-34343400", + "use" : "work" + }], + "address" : [{ + "use" : "work", + "line" : ["Houttuinen 27"], + "city" : "Dordrecht", + "postalCode" : "3311 CE", + "country" : "NL" + }] + } + }, + { + "fullUrl" : "urn:uuid:ad84b7a2-b4dd-474e-bef3-0779e6cb595f", + "resource" : { + "resourceType" : "Condition", + "id" : "ad84b7a2-b4dd-474e-bef3-0779e6cb595f", + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Generated Narrative: Condition</b><a name=\"ad84b7a2-b4dd-474e-bef3-0779e6cb595f\"> </a></p><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\">Resource Condition "ad84b7a2-b4dd-474e-bef3-0779e6cb595f" </p></div><p><b>identifier</b>: id: cacceb57-395f-48e1-9c88-e9c9704dc2d2</p><p><b>clinicalStatus</b>: Active <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"http://terminology.hl7.org/5.0.0/CodeSystem-condition-clinical.html\">Condition Clinical Status Codes</a>#active)</span></p><p><b>verificationStatus</b>: Confirmed <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"http://terminology.hl7.org/5.0.0/CodeSystem-condition-ver-status.html\">ConditionVerificationStatus</a>#confirmed)</span></p><p><b>category</b>: Problem <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"https://loinc.org/\">LOINC</a>#75326-9)</span></p><p><b>severity</b>: Moderate <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"https://loinc.org/\">LOINC</a>#LA6751-7)</span></p><p><b>code</b>: Menopausal flushing (finding) <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"https://browser.ihtsdotools.org/\">SNOMED CT</a>#198436008; <a href=\"http://terminology.hl7.org/5.0.0/CodeSystem-icd10.html\">ICD-10</a>#N95.1 "Menopausal and female climacteric states")</span></p><p><b>subject</b>: <a href=\"#Patient_7685713c-e29e-4a75-8a90-45be7ba3be94\">See above (Patient/7685713c-e29e-4a75-8a90-45be7ba3be94)</a></p><p><b>onset</b>: 2015</p><p><b>recordedDate</b>: 2016-10</p></div>" + }, + "identifier" : [{ + "system" : "urn:oid:1.2.3.999", + "value" : "cacceb57-395f-48e1-9c88-e9c9704dc2d2" + }], + "clinicalStatus" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code" : "active" + }] + }, + "verificationStatus" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code" : "confirmed" + }] + }, + "category" : [{ + "coding" : [{ + "system" : "http://loinc.org", + "code" : "75326-9", + "display" : "Problem" + }] + }], + "severity" : { + "coding" : [{ + "system" : "http://loinc.org", + "code" : "LA6751-7", + "display" : "Moderate" + }] + }, + "code" : { + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "198436008", + "display" : "Menopausal flushing (finding)", + "_display" : { + "extension" : [{ + "extension" : [{ + "url" : "lang", + "valueCode" : "nl-NL" + }, + { + "url" : "content", + "valueString" : "opvliegers" + }], + "url" : "http://hl7.org/fhir/StructureDefinition/translation" + }] + } + }, + { + "system" : "http://hl7.org/fhir/sid/icd-10", + "code" : "N95.1", + "display" : "Menopausal and female climacteric states" + }] + }, + "subject" : { + "reference" : "Patient/7685713c-e29e-4a75-8a90-45be7ba3be94" + }, + "onsetDateTime" : "2015", + "recordedDate" : "2016-10" + } + }, + { + "fullUrl" : "urn:uuid:6e883e5e-7648-485a-86de-3640a61601fe", + "resource" : { + "resourceType" : "MedicationStatement", + "id" : "6e883e5e-7648-485a-86de-3640a61601fe", + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Generated Narrative: MedicationStatement</b><a name=\"6e883e5e-7648-485a-86de-3640a61601fe\"> </a></p><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\">Resource MedicationStatement "6e883e5e-7648-485a-86de-3640a61601fe" </p></div><p><b>identifier</b>: id: 8faf0319-89d3-427c-b9d1-e8c8fd390dca</p><p><b>status</b>: active</p><p><b>medication</b>: <a href=\"#Medication_6369a973-afc7-4617-8877-3e9811e05a5b\">See above (Medication/6369a973-afc7-4617-8877-3e9811e05a5b)</a></p><p><b>subject</b>: <a href=\"#Patient_7685713c-e29e-4a75-8a90-45be7ba3be94\">See above (Patient/7685713c-e29e-4a75-8a90-45be7ba3be94)</a></p><p><b>effective</b>: 2015-03 --> (ongoing)</p><blockquote><p><b>dosage</b></p><p><b>timing</b>: Count 1 times, Do Once</p><p><b>route</b>: Oral use <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (standardterms.edqm.eu#20053000)</span></p></blockquote></div>" + }, + "identifier" : [{ + "system" : "urn:oid:1.2.3.999", + "value" : "8faf0319-89d3-427c-b9d1-e8c8fd390dca" + }], + "status" : "active", + "medicationReference" : { + "reference" : "Medication/6369a973-afc7-4617-8877-3e9811e05a5b" + }, + "subject" : { + "reference" : "Patient/7685713c-e29e-4a75-8a90-45be7ba3be94" + }, + "effectivePeriod" : { + "start" : "2015-03" + }, + "dosage" : [{ + "timing" : { + "repeat" : { + "count" : 1, + "periodUnit" : "d" + } + }, + "route" : { + "coding" : [{ + "system" : "http://standardterms.edqm.eu", + "code" : "20053000", + "display" : "Oral use" + }] + }, + "doseAndRate" : [{ + "type" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code" : "ordered", + "display" : "Ordered" + }] + }, + "doseQuantity" : { + "value" : 1, + "unit" : "tablet", + "system" : "http://unitsofmeasure.org", + "code" : "1" + } + }] + }] + } + }, + { + "fullUrl" : "urn:uuid:6369a973-afc7-4617-8877-3e9811e05a5b", + "resource" : { + "resourceType" : "Medication", + "id" : "6369a973-afc7-4617-8877-3e9811e05a5b", + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Generated Narrative: Medication</b><a name=\"6369a973-afc7-4617-8877-3e9811e05a5b\"> </a></p><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\">Resource Medication "6369a973-afc7-4617-8877-3e9811e05a5b" </p></div><p><b>code</b>: Product containing anastrozole (medicinal product) <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"https://browser.ihtsdotools.org/\">SNOMED CT</a>#108774000; unknown#99872 "ANASTROZOL 1MG TABLET"; unknown#2076667 "ANASTROZOL CF TABLET FILMOMHULD 1MG"; <a href=\"http://terminology.hl7.org/5.0.0/CodeSystem-v3-WC.html\">WHO ATC</a>#L02BG03 "anastrozole")</span></p></div>" + }, + "code" : { + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "108774000", + "display" : "Product containing anastrozole (medicinal product)" + }, + { + "system" : "urn:oid:2.16.840.1.113883.2.4.4.1", + "code" : "99872", + "display" : "ANASTROZOL 1MG TABLET" + }, + { + "system" : "urn:oid:2.16.840.1.113883.2.4.4.7", + "code" : "2076667", + "display" : "ANASTROZOL CF TABLET FILMOMHULD 1MG" + }, + { + "system" : "http://www.whocc.no/atc", + "code" : "L02BG03", + "display" : "anastrozole" + }] + } + } + }, + { + "fullUrl" : "urn:uuid:fe2769fd-22c9-4307-9122-ee0466e5aebb", + "resource" : { + "resourceType" : "AllergyIntolerance", + "id" : "fe2769fd-22c9-4307-9122-ee0466e5aebb", + "text" : { + "status" : "generated", + "div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p><b>Generated Narrative: AllergyIntolerance</b><a name=\"fe2769fd-22c9-4307-9122-ee0466e5aebb\"> </a></p><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\">Resource AllergyIntolerance "fe2769fd-22c9-4307-9122-ee0466e5aebb" </p></div><p><b>identifier</b>: id: 8d9566a4-d26d-46be-a3e4-c9f3a0e5cd83</p><p><b>clinicalStatus</b>: Active <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"http://terminology.hl7.org/5.0.0/CodeSystem-allergyintolerance-clinical.html\">AllergyIntolerance Clinical Status Codes</a>#active)</span></p><p><b>verificationStatus</b>: Confirmed <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"http://terminology.hl7.org/5.0.0/CodeSystem-allergyintolerance-verification.html\">AllergyIntolerance Verification Status</a>#confirmed)</span></p><p><b>type</b>: allergy</p><p><b>category</b>: medication</p><p><b>criticality</b>: high</p><p><b>code</b>: Substance with penicillin structure and antibacterial mechanism of action (substance) <span style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"> (<a href=\"https://browser.ihtsdotools.org/\">SNOMED CT</a>#373270004)</span></p><p><b>patient</b>: <a href=\"#Patient_7685713c-e29e-4a75-8a90-45be7ba3be94\">See above (Patient/7685713c-e29e-4a75-8a90-45be7ba3be94)</a></p><p><b>onset</b>: 2010</p></div>" + }, + "identifier" : [{ + "system" : "urn:oid:1.2.3.999", + "value" : "8d9566a4-d26d-46be-a3e4-c9f3a0e5cd83" + }], + "clinicalStatus" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + "code" : "active" + }] + }, + "verificationStatus" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification", + "code" : "confirmed" + }] + }, + "type" : "allergy", + "category" : ["medication"], + "criticality" : "high", + "code" : { + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "373270004", + "display" : "Substance with penicillin structure and antibacterial mechanism of action (substance)" + }] + }, + "patient" : { + "reference" : "Patient/7685713c-e29e-4a75-8a90-45be7ba3be94" + }, + "onsetDateTime" : "2010" + } + }] +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 821ae93ccd..217a09ebac 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -49,6 +49,8 @@ include(":contrib:barcode") include(":datacapture") +include(":document") + include(":demo") include(":engine") From 397ead631588d35aada30d7bd9bc0f12c132abac Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Thu, 30 Nov 2023 19:02:15 +0530 Subject: [PATCH 44/69] Demo app reskinning: patient list screen. (#2336) * Patient list Screen a. Remove start and end margin of the divider b. align patient counts with patient list * Move hard coded value to resource file. --------- Co-authored-by: Santosh Pingle <spingle@google.com> --- demo/src/main/res/layout/patient_list_view.xml | 4 +--- demo/src/main/res/values/dimens.xml | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/demo/src/main/res/layout/patient_list_view.xml b/demo/src/main/res/layout/patient_list_view.xml index 603086892d..08c1eed961 100644 --- a/demo/src/main/res/layout/patient_list_view.xml +++ b/demo/src/main/res/layout/patient_list_view.xml @@ -25,7 +25,7 @@ <TextView android:id="@+id/patient_count" android:text="0 Patients" - android:layout_centerHorizontal="true" + android:layout_marginStart="@dimen/patient_count_start_margin" android:layout_width="wrap_content" android:layout_height="wrap_content" /> @@ -36,8 +36,6 @@ android:name="com.google.fhirengine.example.PatientListFragment" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginLeft="16dp" - android:layout_marginRight="16dp" android:scrollbars="vertical" app:layoutManager="LinearLayoutManager" tools:context=".PatientListActivity" diff --git a/demo/src/main/res/values/dimens.xml b/demo/src/main/res/values/dimens.xml index a4c743e50d..edc3804783 100644 --- a/demo/src/main/res/values/dimens.xml +++ b/demo/src/main/res/values/dimens.xml @@ -22,5 +22,6 @@ <dimen name="text_margin">20dp</dimen> <dimen name="small_text_margin">2dp</dimen> <dimen name="status_margin">5dp</dimen> - <dimen name="header_height">170dp</dimen> + <dimen name="header_height">170dp</dimen> + <dimen name="patient_count_start_margin">65dp</dimen> </resources> From c22861fcac16a063c89052d9b014ff124c88cf73 Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Thu, 30 Nov 2023 20:23:24 +0530 Subject: [PATCH 45/69] Demo app reskinning : patient details screen (#2337) * Remove patient details left and right margin. Change patient details card background color. * Add start margin to the Header. --------- Co-authored-by: Santosh Pingle <spingle@google.com> --- demo/src/main/res/layout/patient_detail.xml | 1 - demo/src/main/res/layout/patient_details_card_view.xml | 1 + demo/src/main/res/values/colors.xml | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/src/main/res/layout/patient_detail.xml b/demo/src/main/res/layout/patient_detail.xml index b19c8ce656..35b6467cde 100644 --- a/demo/src/main/res/layout/patient_detail.xml +++ b/demo/src/main/res/layout/patient_detail.xml @@ -25,7 +25,6 @@ android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_margin="@dimen/fab_margin" android:scrollbars="vertical" /> </FrameLayout> diff --git a/demo/src/main/res/layout/patient_details_card_view.xml b/demo/src/main/res/layout/patient_details_card_view.xml index 32d3dcf6f1..1a9db16c1b 100644 --- a/demo/src/main/res/layout/patient_details_card_view.xml +++ b/demo/src/main/res/layout/patient_details_card_view.xml @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8" ?> <com.google.android.material.textview.MaterialTextView android:id="@+id/header" + android:layout_marginStart="@dimen/text_margin" android:paddingVertical="10dp" android:orientation="vertical" xmlns:android="http://schemas.android.com/apk/res/android" diff --git a/demo/src/main/res/values/colors.xml b/demo/src/main/res/values/colors.xml index 2cc0f1f91f..cb5eb3850e 100644 --- a/demo/src/main/res/values/colors.xml +++ b/demo/src/main/res/values/colors.xml @@ -21,7 +21,7 @@ <color name="moderate_risk_background">#fff8e1</color> <color name="high_risk">#c62828</color> <color name="high_risk_background">#ffebee</color> - <color name="unknown_risk_background">#10156125</color> + <color name="unknown_risk_background">#DADCE0</color> <color name="unknown_risk">#ffffff</color> <color name="syncing_background">#E8F0FE</color> <color name="disable_state">#9AFFFFFF</color> From aab9b1ea82ce23229c0e27533160ff664d13dbd4 Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Thu, 30 Nov 2023 22:40:43 +0530 Subject: [PATCH 46/69] Api to clear all answers of questionnaire response. (#2329) * api to clear all answers of questionnaire response. * Address review comment. * Address review comments. --------- Co-authored-by: Santosh Pingle <spingle@google.com> --- .../test/QuestionnaireUiEspressoTest.kt | 21 +++- .../fhir/datacapture/QuestionnaireFragment.kt | 2 + .../datacapture/QuestionnaireViewModel.kt | 9 ++ .../datacapture/QuestionnaireViewModelTest.kt | 97 +++++++++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt index 9df329396e..b034a89d67 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt @@ -478,7 +478,25 @@ class QuestionnaireUiEspressoTest { } } - private fun buildFragmentFromQuestionnaire(fileName: String, isReviewMode: Boolean = false) { + @Test + fun clearAllAnswers_shouldClearDraftAnswer() { + val questionnaireFragment = buildFragmentFromQuestionnaire("/component_date_picker.json") + // Add month and day. No need to add slashes as they are added automatically + onView(withId(R.id.text_input_edit_text)) + .perform(ViewActions.click()) + .perform(ViewActions.typeTextIntoFocusedView("0105")) + + questionnaireFragment.clearAllAnswers() + + onView(withId(R.id.text_input_edit_text)).check { view, _ -> + assertThat((view as TextInputEditText).text.toString()).isEmpty() + } + } + + private fun buildFragmentFromQuestionnaire( + fileName: String, + isReviewMode: Boolean = false, + ): QuestionnaireFragment { val questionnaireJsonString = readFileFromAssets(fileName) val questionnaireFragment = QuestionnaireFragment.builder() @@ -491,6 +509,7 @@ class QuestionnaireUiEspressoTest { add(R.id.container_holder, questionnaireFragment) } } + return questionnaireFragment } private fun buildFragmentFromQuestionnaire( diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 4bead5d1bd..70cc0b86b8 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -293,6 +293,8 @@ class QuestionnaireFragment : Fragment() { */ fun getQuestionnaireResponse() = viewModel.getQuestionnaireResponse() + fun clearAllAnswers() = viewModel.clearAllAnswers() + /** Helper to create [QuestionnaireFragment] with appropriate [Bundle] arguments. */ class Builder { 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 8c5d551cca..17ecd2cb0b 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 @@ -421,6 +421,15 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } } + /** Clears all the answers from the questionnaire response by iterating through each item. */ + fun clearAllAnswers() { + questionnaireResponse.allItems.forEach { it.answer = emptyList() } + draftAnswerMap.clear() + modifiedQuestionnaireResponseItemSet.clear() + responseItemToAnswersMapForDisabledQuestionnaireItem.clear() + modificationCount.update { it + 1 } + } + /** * Validates entire questionnaire and return the validation results. As a side effect, it triggers * the UI update to show errors in case there are any validation errors. 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 234441c622..299545eacb 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 @@ -3873,6 +3873,103 @@ class QuestionnaireViewModelTest { assertResourceEquals(value, expectedResponse) } + @Test + fun `clearAllAnswers clears all answers in questionnaire response`() { + val questionnaireString = + """ + { + "resourceType": "Questionnaire", + "id": "client-registration-sample", + "item": [ + { + "linkId": "1", + "type": "group", + "item": [ + { + "linkId": "1.1", + "text": "First Nested Item", + "type": "boolean" + }, + { + "linkId": "1.2", + "text": "Second Nested Item", + "type": "boolean" + } + ] + } + ] + } + """ + .trimIndent() + + val questionnaireResponseString = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "1", + "item": [ + { + "linkId": "1.1", + "text": "First Nested Item", + "answer": [ + { + "valueBoolean": true + } + ] + }, + { + "linkId": "1.2", + "text": "Second Nested Item", + "answer": [ + { + "valueBoolean": true + } + ] + } + ] + } + ] + } + """ + .trimIndent() + + val expectedResponseString = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "1", + "item": [ + { + "linkId": "1.1", + "text": "First Nested Item" + }, + { + "linkId": "1.2", + "text": "Second Nested Item" + } + ] + } + ] + } + """ + .trimIndent() + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString) + state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString) + val viewModel = QuestionnaireViewModel(context, state) + viewModel.clearAllAnswers() + val value = viewModel.getQuestionnaireResponse() + val expectedResponse = + printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) + as QuestionnaireResponse + + assertResourceEquals(value, expectedResponse) + } + // ==================================================================== // // // // Questionnaire Response with Nested Items // From 0a201ce4b88be721caa7dd06d7944268878db048 Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Mon, 4 Dec 2023 17:37:08 +0530 Subject: [PATCH 47/69] update min sdk version 33 (#2347) Co-authored-by: Santosh Pingle <spingle@google.com> --- .../fhir/datacapture/test/QuestionnaireUiEspressoTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt index b034a89d67..96b75cba10 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt @@ -30,6 +30,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum @@ -479,6 +480,7 @@ class QuestionnaireUiEspressoTest { } @Test + @SdkSuppress(minSdkVersion = 33) fun clearAllAnswers_shouldClearDraftAnswer() { val questionnaireFragment = buildFragmentFromQuestionnaire("/component_date_picker.json") // Add month and day. No need to add slashes as they are added automatically From ef3a39ef5e50561d585e274d18a7f5be11e3b811 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:12:16 +0000 Subject: [PATCH 48/69] Bump com.squareup:kotlinpoet from 1.14.2 to 1.15.2 (#2351) Bumps [com.squareup:kotlinpoet](https://github.com/square/kotlinpoet) from 1.14.2 to 1.15.2. - [Release notes](https://github.com/square/kotlinpoet/releases) - [Changelog](https://github.com/square/kotlinpoet/blob/main/docs/changelog.md) - [Commits](https://github.com/square/kotlinpoet/compare/1.14.2...1.15.2) --- updated-dependencies: - dependency-name: com.squareup:kotlinpoet dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 52f93a7af6..549294241b 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -19,5 +19,5 @@ dependencies { implementation("com.spotify.ruler:ruler-gradle-plugin:1.4.0") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.8.0") - implementation("com.squareup:kotlinpoet:1.14.2") + implementation("com.squareup:kotlinpoet:1.15.2") } From 19e8a89df0cb1a39bcee179f8e047634f351497f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Dec 2023 15:05:37 +0000 Subject: [PATCH 49/69] Bump actions/setup-java from 3 to 4 (#2352) Bumps [actions/setup-java](https://github.com/actions/setup-java) from 3 to 4. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/setup-java dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- .github/workflows/codeql.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 1f90d2cb89..1f52273e93 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -52,7 +52,7 @@ jobs: uses: actions/checkout@v4 - name: Switch to Java 17 from Eclipse Temurin distro - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin From 79fb87da54625e2a0c371eeb6b1206784b04df52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Dec 2023 16:07:56 +0000 Subject: [PATCH 50/69] Bump com.fasterxml.jackson.datatype:jackson-datatype-jsr310 (#2350) Bumps com.fasterxml.jackson.datatype:jackson-datatype-jsr310 from 2.15.3 to 2.16.0. --- updated-dependencies: - dependency-name: com.fasterxml.jackson.datatype:jackson-datatype-jsr310 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 217a09ebac..c5f1ef61e0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,7 +36,7 @@ buildscript { classpath("com.fasterxml.jackson.core:jackson-databind:2.15.3") classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.15.3") classpath("com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.16.0") - classpath("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.3") + classpath("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") classpath("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") } } From b9a6124abaaf7ad719302d3a643192deedeb3689 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Dec 2023 20:36:05 +0000 Subject: [PATCH 51/69] Bump com.fasterxml.jackson.core:jackson-databind from 2.15.3 to 2.16.0 (#2349) Bumps [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) from 2.15.3 to 2.16.0. - [Commits](https://github.com/FasterXML/jackson/commits) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.core:jackson-databind dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index c5f1ef61e0..507bbb8b42 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,7 +33,7 @@ buildscript { dependencies { classpath("com.fasterxml.jackson.core:jackson-core:2.15.3") classpath("com.fasterxml.jackson.core:jackson-annotations:2.15.3") - classpath("com.fasterxml.jackson.core:jackson-databind:2.15.3") + classpath("com.fasterxml.jackson.core:jackson-databind:2.16.0") classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.15.3") classpath("com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.16.0") classpath("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") From 65d7c1f681129052ed75021a27ef1cb5007ed3aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Dec 2023 10:32:12 +0000 Subject: [PATCH 52/69] Bump com.fasterxml.jackson.dataformat:jackson-dataformat-xml (#2339) Bumps [com.fasterxml.jackson.dataformat:jackson-dataformat-xml](https://github.com/FasterXML/jackson-dataformat-xml) from 2.15.3 to 2.16.0. - [Commits](https://github.com/FasterXML/jackson-dataformat-xml/compare/jackson-dataformat-xml-2.15.3...jackson-dataformat-xml-2.16.0) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.dataformat:jackson-dataformat-xml dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 507bbb8b42..b719de5c17 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,7 +34,7 @@ buildscript { classpath("com.fasterxml.jackson.core:jackson-core:2.15.3") classpath("com.fasterxml.jackson.core:jackson-annotations:2.15.3") classpath("com.fasterxml.jackson.core:jackson-databind:2.16.0") - classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.15.3") + classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.16.0") classpath("com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.16.0") classpath("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") classpath("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") From 088d381f0a3bfd943ba8ca592183e74034e36a95 Mon Sep 17 00:00:00 2001 From: Nitin Sabale <82660329+nsabale7@users.noreply.github.com> Date: Fri, 8 Dec 2023 21:16:11 +0530 Subject: [PATCH 53/69] Repeated Group: Fix Definition based extraction for multiple answers (#1911) * Iterating questionnaire response items and finding questionnaire item to extract * feedback changes * Replace find for currentQuestionnaireItem with map as per the discussion in the PR * Modified logic for zipByLinkId and used it. * feedback and Test * remove additional comments --------- Co-authored-by: Jing Tang <jingtang@google.com> --- .../MoreQuestionnaireItemComponents.kt | 16 +-- .../datacapture/mapping/ResourceMapper.kt | 37 ++--- .../MoreQuestionnaireItemComponentsTest.kt | 76 ++++++++++ .../datacapture/mapping/ResourceMapperTest.kt | 136 +++++++++++++++--- 4 files changed, 211 insertions(+), 54 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt index 5ae1cd30b8..bffc30bbfe 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt @@ -769,10 +769,9 @@ internal fun Questionnaire.QuestionnaireItemComponent.extractAnswerOptions( * `questionnaireResponseItemList` with the same linkId using the provided `transform` function * applied to each pair of questionnaire item and questionnaire response item. * - * It is assumed that the linkIds are unique in `this` and in `questionnaireResponseItemList`. - * - * Although linkIds may appear more than once in questionnaire response, they would not appear more - * than once within a list of questionnaire response items sharing the same parent. + * In case of repeated group item, `questionnaireResponseItemList` will contain + * QuestionnaireResponseItemComponent with same linkId. So these items are grouped with linkId and + * associated with its questionnaire item linkId. */ internal inline fun <T> List<Questionnaire.QuestionnaireItemComponent>.zipByLinkId( questionnaireResponseItemList: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>, @@ -782,12 +781,13 @@ internal inline fun <T> List<Questionnaire.QuestionnaireItemComponent>.zipByLink QuestionnaireResponse.QuestionnaireResponseItemComponent, ) -> T, ): List<T> { - val linkIdToQuestionnaireResponseItemMap = questionnaireResponseItemList.associateBy { it.linkId } - return mapNotNull { questionnaireItem -> - linkIdToQuestionnaireResponseItemMap[questionnaireItem.linkId]?.let { questionnaireResponseItem, - -> + val linkIdToQuestionnaireResponseItemListMap = questionnaireResponseItemList.groupBy { it.linkId } + return flatMap { questionnaireItem -> + linkIdToQuestionnaireResponseItemListMap[questionnaireItem.linkId]?.mapNotNull { + questionnaireResponseItem -> transform(questionnaireItem, questionnaireResponseItem) } + ?: emptyList() } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt index 3f49256162..c4e3a457fb 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt @@ -28,6 +28,7 @@ import com.google.android.fhir.datacapture.extensions.toCoding import com.google.android.fhir.datacapture.extensions.toIdType import com.google.android.fhir.datacapture.extensions.toUriType import com.google.android.fhir.datacapture.extensions.validateLaunchContextExtensions +import com.google.android.fhir.datacapture.extensions.zipByLinkId import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine import java.lang.reflect.Field import java.lang.reflect.Method @@ -290,31 +291,17 @@ object ResourceMapper { extractionResult: MutableList<Resource>, profileLoader: ProfileLoader, ) { - val questionnaireItemListIterator = questionnaireItemList.iterator() - val questionnaireResponseItemListIterator = questionnaireResponseItemList.iterator() - while ( - questionnaireItemListIterator.hasNext() && questionnaireResponseItemListIterator.hasNext() - ) { - val currentQuestionnaireResponseItem = questionnaireResponseItemListIterator.next() - var currentQuestionnaireItem = questionnaireItemListIterator.next() - // Find the next questionnaire item with the same link ID. This is necessary because some - // questionnaire items that are disabled might not have corresponding questionnaire response - // items. - while ( - questionnaireItemListIterator.hasNext() && - currentQuestionnaireItem.linkId != currentQuestionnaireResponseItem.linkId - ) { - currentQuestionnaireItem = questionnaireItemListIterator.next() - } - if (currentQuestionnaireItem.linkId == currentQuestionnaireResponseItem.linkId) { - extractByDefinition( - currentQuestionnaireItem, - currentQuestionnaireResponseItem, - extractionContext, - extractionResult, - profileLoader, - ) - } + questionnaireItemList.zipByLinkId(questionnaireResponseItemList) { + questionnaireItem, + questionnaireResponseItem, + -> + extractByDefinition( + questionnaireItem, + questionnaireResponseItem, + extractionContext, + extractionResult, + profileLoader, + ) } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt index 6fb12f5bb2..9def709e9b 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt @@ -39,6 +39,7 @@ import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.utils.ToolingExtensions import org.junit.Assert.assertThrows @@ -2295,6 +2296,81 @@ class MoreQuestionnaireItemComponentsTest { assertThat(questionnaireItem.dateEntryFormatOrSystemDefault).isEqualTo("y-MM-dd") } + @Test + fun `should return empty list for empty response item list with same linkId`() { + val questionnaireItemComponentList = + listOf( + Questionnaire.QuestionnaireItemComponent().apply { linkId = "1" }, + ) + + val questionnaireResponseItemComponentList = + listOf<QuestionnaireResponse.QuestionnaireResponseItemComponent>() + + val result = + questionnaireItemComponentList.zipByLinkId(questionnaireResponseItemComponentList) { _, _ -> } + assertThat(result.size).isEqualTo(0) + } + + @Test + fun `should return non empty list for valid questionnaire item and response list`() { + val questionnaireItemComponentList = + listOf( + Questionnaire.QuestionnaireItemComponent().apply { linkId = "1" }, + Questionnaire.QuestionnaireItemComponent().apply { linkId = "2" }, + ) + + val questionnaireResponseItemComponentList = + listOf( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "1" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "2" }, + ) + + val zipList = + questionnaireItemComponentList.zipByLinkId(questionnaireResponseItemComponentList) { _, _ -> } + assertThat(zipList.size).isEqualTo(2) + } + + @Test + fun `should return non empty list for valid questionnaire item and repeated response list with same linkId`() { + val questionnaireItemComponentList = + listOf( + Questionnaire.QuestionnaireItemComponent().apply { linkId = "1" }, + Questionnaire.QuestionnaireItemComponent().apply { linkId = "2" }, + ) + + val questionnaireResponseItemComponentList = + listOf( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "1" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "2" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "2" }, + ) + + val zipList = + questionnaireItemComponentList.zipByLinkId(questionnaireResponseItemComponentList) { _, _ -> } + assertThat(zipList.size).isEqualTo(3) + } + + @Test + fun `should return non empty list for out of order questionnaire item and response item`() { + val questionnaireItemComponentList = + listOf( + Questionnaire.QuestionnaireItemComponent().apply { linkId = "1" }, + Questionnaire.QuestionnaireItemComponent().apply { linkId = "3" }, + Questionnaire.QuestionnaireItemComponent().apply { linkId = "2" }, + ) + + val questionnaireResponseItemComponentList = + listOf( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "3" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "2" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "1" }, + ) + + val zipList = + questionnaireItemComponentList.zipByLinkId(questionnaireResponseItemComponentList) { _, _ -> } + assertThat(zipList.size).isEqualTo(3) + } + private val displayCategoryExtensionWithInstructionsCode = Extension().apply { url = EXTENSION_DISPLAY_CATEGORY_URL diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt index cb9e0f2b2d..df84802581 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt @@ -1027,27 +1027,6 @@ class ResourceMapperTest { } ] }, - { - "linkId": "PR-address", - "item": [ - { - "linkId": "PR-address-city", - "answer": [ - { - "valueString": "Nairobi" - } - ] - }, - { - "linkId": "PR-address-country", - "answer": [ - { - "valueString": "Kenya" - } - ] - } - ] - }, { "linkId": "PR-active" } @@ -1177,6 +1156,121 @@ class ResourceMapperTest { assertThat(observation.valueQuantity.value).isEqualTo(BigDecimal(90)) } + @Test + fun `extract() should perform definition-based extraction for repeated groups`() = runBlocking { + @Language("JSON") + val questionnaireJson = + """ + { + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "repeated-parent", + "type": "group", + "repeats": true, + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemExtractionContext", + "valueExpression": { + "expression": "Observation" + } + } + ], + "item": [ + { + "linkId": "1.0", + "type": "group", + "definition": "http://hl7.org/fhir/StructureDefinition/Observation#Observation.valueCodeableConcept", + "item": [ + { + "linkId": "1.0.1", + "type": "choice", + "definition": "http://hl7.org/fhir/StructureDefinition/Observation#Observation.valueCodeableConcept.coding" + } + ] + } + ] + } + ] + } + """ + .trimIndent() + + @Language("JSON") + val questionnaireResponseJson = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "repeated-parent", + "item": [ + { + "linkId": "1.0", + "item": [ + { + "linkId": "1.0.1", + "answer": [ + { + "valueCoding": { + "system": "test-coding-system", + "code": "test-coding-code-1", + "display": "Test Coding Display 1" + } + } + ] + } + ] + } + ] + }, + { + "linkId": "repeated-parent", + "item": [ + { + "linkId": "1.0", + "item": [ + { + "linkId": "1.0.1", + "answer": [ + { + "valueCoding": { + "system": "test-coding-system", + "code": "test-coding-code-2", + "display": "Test Coding Display 2" + } + } + ] + } + ] + } + ] + } + ] + } + """ + .trimIndent() + + val uriTestQuestionnaire = + iParser.parseResource(Questionnaire::class.java, questionnaireJson) as Questionnaire + + val uriTestQuestionnaireResponse = + iParser.parseResource(QuestionnaireResponse::class.java, questionnaireResponseJson) + as QuestionnaireResponse + + val bundle = ResourceMapper.extract(uriTestQuestionnaire, uriTestQuestionnaireResponse) + + val observation1 = bundle.entry.first().resource as Observation + assertThat(observation1.valueCodeableConcept.coding[0].code).isEqualTo("test-coding-code-1") + assertThat(observation1.valueCodeableConcept.coding[0].display) + .isEqualTo("Test Coding Display 1") + + val observation2 = bundle.entry[1].resource as Observation + assertThat(observation2.valueCodeableConcept.coding[0].code).isEqualTo("test-coding-code-2") + assertThat(observation2.valueCodeableConcept.coding[0].display) + .isEqualTo("Test Coding Display 2") + } + @Test fun `populate() should fill QuestionnaireResponse with values when given a single Resource`() = runBlocking { From e3b56219a9fcfa5d8bbad15e0d7b86ae29377929 Mon Sep 17 00:00:00 2001 From: Kashyap Jois <kjois@iprdgroup.com> Date: Fri, 8 Dec 2023 22:22:49 +0530 Subject: [PATCH 54/69] Add Cache-Control to Headers to all the FHIR APIs using okHttp Cache (#2144) * Add okHttp3.Cache to NetworkConfiguration * Add cache if not null to okHttpBuilder * Add non null cache object to demo app . * Added wrapper object instead of exposing okHttp Cache. * spotless * fix broken test --------- Co-authored-by: omarismail <omarismail@google.com> Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- .../google/android/fhir/FhirEngineProvider.kt | 11 ++++++++ .../fhir/sync/remote/RetrofitHttpService.kt | 2 ++ .../android/fhir/FhirEngineProviderTest.kt | 25 ++++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt b/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt index 6498d8feb8..37b0c6c885 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt @@ -21,6 +21,7 @@ import com.google.android.fhir.DatabaseErrorStrategy.UNSPECIFIED import com.google.android.fhir.sync.DataSource import com.google.android.fhir.sync.HttpAuthenticator import com.google.android.fhir.sync.remote.HttpLogger +import java.io.File import org.hl7.fhir.r4.model.SearchParameter /** The provider for [FhirEngine] instance. */ @@ -161,4 +162,14 @@ data class NetworkConfiguration( val writeTimeOut: Long = 10, /** Compresses requests when uploading to a server that supports gzip. */ val uploadWithGzip: Boolean = false, + /** Cache setting to enable Cache-Control Header */ + val httpCache: CacheConfiguration? = null, +) + +/** Cache configuration wrapper */ +data class CacheConfiguration( + /** Cache directory eg: File(application.cacheDir, "http_cache") */ + val cacheDir: File, + /** Cache size in bits eg: 50L * 1024L * 1024L // 50 MiB */ + val maxSize: Long, ) diff --git a/engine/src/main/java/com/google/android/fhir/sync/remote/RetrofitHttpService.kt b/engine/src/main/java/com/google/android/fhir/sync/remote/RetrofitHttpService.kt index f92f674c2f..60e819027f 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/remote/RetrofitHttpService.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/remote/RetrofitHttpService.kt @@ -20,6 +20,7 @@ import com.github.fge.jsonpatch.JsonPatch import com.google.android.fhir.NetworkConfiguration import com.google.android.fhir.sync.HttpAuthenticator import java.util.concurrent.TimeUnit +import okhttp3.Cache import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -106,6 +107,7 @@ internal interface RetrofitHttpService : FhirHttpService { }, ) } + networkConfiguration.httpCache?.let { this.cache(Cache(it.cacheDir, it.maxSize)) } } .build() return Retrofit.Builder() diff --git a/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt b/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt index aba268fde2..117357b0f0 100644 --- a/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt +++ b/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt @@ -19,7 +19,7 @@ package com.google.android.fhir import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat -import java.lang.IllegalStateException +import java.io.File import org.junit.After import org.junit.Test import org.junit.runner.RunWith @@ -95,4 +95,27 @@ class FhirEngineProviderTest { assertThat(this.writeTimeOut).isEqualTo(6) } } + + @Test + fun createFhirEngineConfiguration_configureOkHttpCache_shouldHaveOkHttpCache() { + val config = + FhirEngineConfiguration( + serverConfiguration = + ServerConfiguration( + "", + NetworkConfiguration( + httpCache = + CacheConfiguration( + cacheDir = File("sample-dir", "http_cache"), + // $0.05 worth of phone storage in 2020 + maxSize = 50L * 1024L * 1024L, // 50 MiB + ), + ), + ), + ) + with(config.serverConfiguration!!.networkConfiguration) { + assertThat(this.httpCache?.maxSize).isEqualTo(50L * 1024L * 1024L) + assertThat(this.httpCache?.cacheDir?.path).isEqualTo("sample-dir${File.separator}http_cache") + } + } } From fb62b3bcdd9b8a0491ee45f272c7ec7e01384e99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Dec 2023 17:07:37 +0000 Subject: [PATCH 55/69] Bump com.squareup:kotlinpoet from 1.15.2 to 1.15.3 (#2364) Bumps [com.squareup:kotlinpoet](https://github.com/square/kotlinpoet) from 1.15.2 to 1.15.3. - [Release notes](https://github.com/square/kotlinpoet/releases) - [Changelog](https://github.com/square/kotlinpoet/blob/main/docs/changelog.md) - [Commits](https://github.com/square/kotlinpoet/compare/1.15.2...1.15.3) --- updated-dependencies: - dependency-name: com.squareup:kotlinpoet dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 549294241b..9a8ff8bf57 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -19,5 +19,5 @@ dependencies { implementation("com.spotify.ruler:ruler-gradle-plugin:1.4.0") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.8.0") - implementation("com.squareup:kotlinpoet:1.15.2") + implementation("com.squareup:kotlinpoet:1.15.3") } From aa6b233221e6a38aaed765c42bacd1a847049be4 Mon Sep 17 00:00:00 2001 From: Fikri Milano <fikrimilano1@gmail.com> Date: Tue, 12 Dec 2023 22:37:46 +0700 Subject: [PATCH 56/69] Fix TextWatcher initialization issue (#2358) --- .../views/factories/DatePickerViewHolderFactory.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt index abd1b20836..d15ca93ff2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt @@ -65,7 +65,7 @@ internal object DatePickerViewHolderFactory : private lateinit var textInputEditText: TextInputEditText override lateinit var questionnaireViewItem: QuestionnaireViewItem private lateinit var canonicalizedDatePattern: String - private lateinit var textWatcher: DatePatternTextWatcher + private var textWatcher: TextWatcher? = null override fun init(itemView: View) { header = itemView.findViewById(R.id.header) @@ -108,7 +108,6 @@ internal object DatePickerViewHolderFactory : val datePattern = questionnaireViewItem.questionnaireItem.dateEntryFormatOrSystemDefault // Special character used in date pattern val datePatternSeparator = getDateSeparator(datePattern) - textWatcher = DatePatternTextWatcher(datePatternSeparator) canonicalizedDatePattern = canonicalizeDatePattern(datePattern) with(textInputLayout) { @@ -142,6 +141,7 @@ internal object DatePickerViewHolderFactory : } else { displayValidationResult(questionnaireViewItem.validationResult) } + textWatcher = DatePatternTextWatcher(datePatternSeparator) textInputEditText.addTextChangedListener(textWatcher) } From 85e921da209a2d4d48b1e5fe662941f4263890cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Dec 2023 12:58:44 +0000 Subject: [PATCH 57/69] Bump ca.uhn.hapi.fhir:hapi-fhir-caching-guava from 6.8.0 to 6.10.0 (#2367) Bumps ca.uhn.hapi.fhir:hapi-fhir-caching-guava from 6.8.0 to 6.10.0. --- updated-dependencies: - dependency-name: ca.uhn.hapi.fhir:hapi-fhir-caching-guava dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- common/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index df5bba1412..323e06de02 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -30,7 +30,7 @@ configurations { dependencies { // REVERT to DEPENDENCIES LATER api("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.8.0") - api("ca.uhn.hapi.fhir:hapi-fhir-caching-guava:6.8.0") + api("ca.uhn.hapi.fhir:hapi-fhir-caching-guava:6.10.0") implementation(Dependencies.fhirUcum) From 68c37d7f114ddb049e973bb45356c4199d24c34b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Dec 2023 17:16:27 +0000 Subject: [PATCH 58/69] Bump com.fasterxml.jackson.module:jackson-module-kotlin (#2365) Bumps [com.fasterxml.jackson.module:jackson-module-kotlin](https://github.com/FasterXML/jackson-module-kotlin) from 2.15.3 to 2.16.0. - [Commits](https://github.com/FasterXML/jackson-module-kotlin/compare/jackson-module-kotlin-2.15.3...jackson-module-kotlin-2.16.0) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.module:jackson-module-kotlin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index b719de5c17..2462bb8093 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,7 +37,7 @@ buildscript { classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.16.0") classpath("com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.16.0") classpath("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.0") - classpath("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") + classpath("com.fasterxml.jackson.module:jackson-module-kotlin:2.16.0") } } From b5f8cba13052e6438039ab9f9aebaefc9634e3bc Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Fri, 15 Dec 2023 18:19:19 +0530 Subject: [PATCH 59/69] Stitching Upload Components to support multiple Upload Strategies (#2286) * stitching up * adding more tests for uploader * demo app corrections * Review comments: Refactored code * Review comments : Renamed a few classes --------- Co-authored-by: aditya-07 <adityakhajuria@google.com> --- .../fhir/demo/data/DemoFhirSyncWorker.kt | 3 + .../fhir/benchmark/FhirSyncWorkerBenchmark.kt | 3 + .../android/fhir/db/impl/DatabaseImplTest.kt | 13 +- .../android/fhir/sync/SyncInstrumentedTest.kt | 3 + .../android/fhir/sync/FhirSyncWorker.kt | 20 +- .../android/fhir/sync/FhirSynchronizer.kt | 21 +- .../fhir/sync/upload/ResourceConsolidator.kt | 50 +- .../fhir/sync/upload/UploadStrategy.kt | 58 +- .../android/fhir/sync/upload/Uploader.kt | 121 ++++- .../fhir/sync/upload/patch/PatchGenerator.kt | 15 +- .../upload/patch/PerChangePatchGenerator.kt | 20 +- .../upload/patch/PerResourcePatchGenerator.kt | 11 +- .../request/TransactionBundleGenerator.kt | 33 +- .../upload/request/UploadRequestGenerator.kt | 29 +- .../upload/request/UrlRequestGenerator.kt | 12 +- .../google/android/fhir/testing/Utilities.kt | 21 +- .../android/fhir/impl/FhirEngineImplTest.kt | 9 +- .../android/fhir/sync/FhirSyncWorkerTest.kt | 7 + .../android/fhir/sync/FhirSynchronizerTest.kt | 8 +- .../com/google/android/fhir/sync/SyncTest.kt | 3 + .../android/fhir/sync/upload/UploaderTest.kt | 499 +++++++++++++++++- .../patch/PerResourcePatchGeneratorTest.kt | 98 +++- .../upload/request/IndividualGeneratorTest.kt | 202 +++---- .../request/RequestGeneratorTestUtils.kt | 93 ++++ .../request/TransactionBundleGeneratorTest.kt | 267 +++------- 25 files changed, 1107 insertions(+), 512 deletions(-) create mode 100644 engine/src/test/java/com/google/android/fhir/sync/upload/request/RequestGeneratorTestUtils.kt diff --git a/demo/src/main/java/com/google/android/fhir/demo/data/DemoFhirSyncWorker.kt b/demo/src/main/java/com/google/android/fhir/demo/data/DemoFhirSyncWorker.kt index 80f8042b25..bcaff6ce96 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/data/DemoFhirSyncWorker.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/data/DemoFhirSyncWorker.kt @@ -22,6 +22,7 @@ import com.google.android.fhir.demo.FhirApplication import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.FhirSyncWorker +import com.google.android.fhir.sync.upload.UploadStrategy class DemoFhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : FhirSyncWorker(appContext, workerParams) { @@ -32,5 +33,7 @@ class DemoFhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : override fun getConflictResolver() = AcceptLocalConflictResolver + override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut + override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) } diff --git a/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt b/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt index 1a2904e8b3..59eed58d1b 100644 --- a/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt +++ b/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt @@ -36,6 +36,7 @@ import com.google.android.fhir.sync.AcceptRemoteConflictResolver import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.FhirSyncWorker import com.google.android.fhir.sync.download.DownloadRequest +import com.google.android.fhir.sync.upload.UploadStrategy import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import java.util.LinkedList @@ -88,6 +89,8 @@ class FhirSyncWorkerBenchmark { override fun getDownloadWorkManager(): DownloadWorkManager = BenchmarkTestDownloadManagerImpl() override fun getConflictResolver() = AcceptRemoteConflictResolver + + override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut } open class BenchmarkTestDownloadManagerImpl(queries: List<String> = listOf("List/sync-list")) : 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 86b9ba313d..3eb337b209 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 @@ -40,6 +40,7 @@ import com.google.android.fhir.search.has import com.google.android.fhir.search.include import com.google.android.fhir.search.revInclude import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.ResourceUploadResponseMapping import com.google.android.fhir.sync.upload.UploadSyncResult import com.google.android.fhir.testing.assertJsonArrayEqualsIgnoringOrder import com.google.android.fhir.testing.assertResourceEquals @@ -553,12 +554,14 @@ class DatabaseImplTest { .first { it.resourceId == "remote-patient-3" } .let { UploadSyncResult.Success( - listOf(it), listOf( - Patient().apply { - id = it.resourceId - meta = remoteMeta - }, + ResourceUploadResponseMapping( + listOf(it), + Patient().apply { + id = it.resourceId + meta = remoteMeta + }, + ), ), ) } diff --git a/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt b/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt index bb4f48b8c8..9ee6a01935 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt @@ -25,6 +25,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.testing.WorkManagerTestInitHelper import com.google.android.fhir.FhirEngine +import com.google.android.fhir.sync.upload.UploadStrategy import com.google.android.fhir.testing.TestDataSourceImpl import com.google.android.fhir.testing.TestDownloadManagerImpl import com.google.android.fhir.testing.TestFhirEngineImpl @@ -56,6 +57,8 @@ class SyncInstrumentedTest { override fun getDownloadWorkManager(): DownloadWorkManager = TestDownloadManagerImpl() override fun getConflictResolver() = AcceptRemoteConflictResolver + + override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut } @Test diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt index fe5a78206b..a43e9d4172 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt @@ -25,7 +25,10 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.OffsetDateTimeTypeAdapter import com.google.android.fhir.sync.download.DownloaderImpl +import com.google.android.fhir.sync.upload.UploadStrategy import com.google.android.fhir.sync.upload.Uploader +import com.google.android.fhir.sync.upload.patch.PatchGeneratorFactory +import com.google.android.fhir.sync.upload.request.UploadRequestGeneratorFactory import com.google.gson.ExclusionStrategy import com.google.gson.FieldAttributes import com.google.gson.GsonBuilder @@ -45,6 +48,8 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter abstract fun getConflictResolver(): ConflictResolver + abstract fun getUploadStrategy(): UploadStrategy + private val gson = GsonBuilder() .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeTypeAdapter().nullSafe()) @@ -69,9 +74,18 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter FhirSynchronizer( applicationContext, getFhirEngine(), - Uploader(dataSource), - DownloaderImpl(dataSource, getDownloadWorkManager()), - getConflictResolver(), + UploadConfiguration( + Uploader( + dataSource = dataSource, + patchGenerator = PatchGeneratorFactory.byMode(getUploadStrategy().patchGeneratorMode), + requestGenerator = + UploadRequestGeneratorFactory.byMode(getUploadStrategy().requestGeneratorMode), + ), + ), + DownloadConfiguration( + DownloaderImpl(dataSource, getDownloadWorkManager()), + getConflictResolver(), + ), ) val job = diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt index acac045a14..2959092d83 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt @@ -44,13 +44,21 @@ private sealed class SyncResult { data class ResourceSyncException(val resourceType: ResourceType, val exception: Exception) +internal data class UploadConfiguration( + val uploader: Uploader, +) + +internal class DownloadConfiguration( + val downloader: Downloader, + val conflictResolver: ConflictResolver, +) + /** Class that helps synchronize the data source and save it in the local database */ internal class FhirSynchronizer( context: Context, private val fhirEngine: FhirEngine, - private val uploader: Uploader, - private val downloader: Downloader, - private val conflictResolver: ConflictResolver, + private val uploadConfiguration: UploadConfiguration, + private val downloadConfiguration: DownloadConfiguration, ) { private val _syncState = MutableSharedFlow<SyncJobStatus>() @@ -91,9 +99,9 @@ internal class FhirSynchronizer( private suspend fun download(): SyncResult { val exceptions = mutableListOf<ResourceSyncException>() - fhirEngine.syncDownload(conflictResolver) { + fhirEngine.syncDownload(downloadConfiguration.conflictResolver) { flow { - downloader.download().collect { + downloadConfiguration.downloader.download().collect { when (it) { is DownloadState.Started -> { setSyncState(SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, it.total)) @@ -119,7 +127,8 @@ internal class FhirSynchronizer( private suspend fun upload(): SyncResult { val exceptions = mutableListOf<ResourceSyncException>() val localChangesFetchMode = LocalChangesFetchMode.AllChanges - fhirEngine.syncUpload(localChangesFetchMode, uploader::upload).collect { progress -> + fhirEngine.syncUpload(localChangesFetchMode, uploadConfiguration.uploader::upload).collect { + progress -> progress.uploadError?.let { exceptions.add(it) } ?: setSyncState( SyncJobStatus.InProgress( diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt index 15573f0b89..344ad278d8 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt @@ -16,28 +16,11 @@ package com.google.android.fhir.sync.upload -/* - * Copyright 2023 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. - */ - import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.Database import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.DomainResource import org.hl7.fhir.r4.model.ResourceType -import timber.log.Timber /** * Represents a mechanism to consolidate resources after they are uploaded. @@ -62,12 +45,16 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res when (uploadSyncResult) { is UploadSyncResult.Success -> { database.deleteUpdates( - LocalChangeToken(uploadSyncResult.localChanges.flatMap { it.token.ids }), + LocalChangeToken( + uploadSyncResult.uploadResponses.flatMap { + it.localChanges.flatMap { localChange -> localChange.token.ids } + }, + ), ) - uploadSyncResult.responseResources.forEach { + uploadSyncResult.uploadResponses.forEach { when (it) { - is Bundle -> updateVersionIdAndLastUpdated(it) - else -> updateVersionIdAndLastUpdated(it) + is BundleComponentUploadResponseMapping -> updateVersionIdAndLastUpdated(it.output) + is ResourceUploadResponseMapping -> updateVersionIdAndLastUpdated(it.output) } } } @@ -78,23 +65,6 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res } } - private suspend fun updateVersionIdAndLastUpdated(bundle: Bundle) { - when (bundle.type) { - Bundle.BundleType.TRANSACTIONRESPONSE -> { - bundle.entry.forEach { - when { - it.hasResource() -> updateVersionIdAndLastUpdated(it.resource) - it.hasResponse() -> updateVersionIdAndLastUpdated(it.response) - } - } - } - else -> { - // Leave it for now. - Timber.i("Received request to update meta values for ${bundle.type}") - } - } - } - private suspend fun updateVersionIdAndLastUpdated(response: Bundle.BundleEntryResponseComponent) { if (response.hasEtag() && response.hasLastModified() && response.hasLocation()) { response.resourceIdAndType?.let { (id, type) -> @@ -108,7 +78,7 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res } } - private suspend fun updateVersionIdAndLastUpdated(resource: Resource) { + private suspend fun updateVersionIdAndLastUpdated(resource: DomainResource) { if (resource.hasMeta() && resource.meta.hasVersionId() && resource.meta.hasLastUpdated()) { database.updateVersionIdAndLastUpdated( resource.id, diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt index ef082becc3..bc97f6e860 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt @@ -35,59 +35,65 @@ private constructor( internal val patchGeneratorMode: PatchGeneratorMode, internal val requestGeneratorMode: UploadRequestGeneratorMode, ) { - object SingleChangePut : - UploadStrategy( - LocalChangesFetchMode.EarliestChange, - PatchGeneratorMode.PerChange, - UploadRequestGeneratorMode.UrlRequest(HttpVerb.PUT, HttpVerb.PATCH), - ) - object SingleChangePost : + object AllChangesBundlePut : UploadStrategy( - LocalChangesFetchMode.EarliestChange, + LocalChangesFetchMode.AllChanges, PatchGeneratorMode.PerChange, - UploadRequestGeneratorMode.UrlRequest(HttpVerb.POST, HttpVerb.PATCH), + UploadRequestGeneratorMode.BundleRequest(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH), ) - object SingleResourcePut : + object AllChangesSquashedBundlePut : UploadStrategy( - LocalChangesFetchMode.PerResource, + LocalChangesFetchMode.AllChanges, PatchGeneratorMode.PerResource, - UploadRequestGeneratorMode.UrlRequest(HttpVerb.PUT, HttpVerb.PATCH), + UploadRequestGeneratorMode.BundleRequest(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH), ) - object SingleResourcePost : + /** + * All the [UploadStrategy]s below this line are still in progress and not available as of now. As + * and when an [UploadStrategy] is implemented, it should be moved above this comment section and + * made non private. + */ + private object AllChangesSquashedBundlePost : UploadStrategy( - LocalChangesFetchMode.PerResource, + LocalChangesFetchMode.AllChanges, PatchGeneratorMode.PerResource, - UploadRequestGeneratorMode.UrlRequest(HttpVerb.POST, HttpVerb.PATCH), + UploadRequestGeneratorMode.BundleRequest(Bundle.HTTPVerb.POST, Bundle.HTTPVerb.PATCH), ) - object AllChangesBundlePut : + private object SingleChangePut : UploadStrategy( - LocalChangesFetchMode.AllChanges, + LocalChangesFetchMode.EarliestChange, PatchGeneratorMode.PerChange, - UploadRequestGeneratorMode.BundleRequest(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH), + UploadRequestGeneratorMode.UrlRequest(HttpVerb.PUT, HttpVerb.PATCH), ) - object AllChangesBundlePost : + private object SingleChangePost : UploadStrategy( - LocalChangesFetchMode.AllChanges, + LocalChangesFetchMode.EarliestChange, PatchGeneratorMode.PerChange, - UploadRequestGeneratorMode.BundleRequest(Bundle.HTTPVerb.POST, Bundle.HTTPVerb.PATCH), + UploadRequestGeneratorMode.UrlRequest(HttpVerb.POST, HttpVerb.PATCH), ) - object AllChangesSquashedBundlePut : + private object SingleResourcePut : UploadStrategy( - LocalChangesFetchMode.AllChanges, + LocalChangesFetchMode.PerResource, PatchGeneratorMode.PerResource, - UploadRequestGeneratorMode.BundleRequest(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH), + UploadRequestGeneratorMode.UrlRequest(HttpVerb.PUT, HttpVerb.PATCH), ) - object AllChangesSquashedBundlePost : + private object SingleResourcePost : UploadStrategy( - LocalChangesFetchMode.AllChanges, + LocalChangesFetchMode.PerResource, PatchGeneratorMode.PerResource, + UploadRequestGeneratorMode.UrlRequest(HttpVerb.POST, HttpVerb.PATCH), + ) + + private object AllChangesBundlePost : + UploadStrategy( + LocalChangesFetchMode.AllChanges, + PatchGeneratorMode.PerChange, UploadRequestGeneratorMode.BundleRequest(Bundle.HTTPVerb.POST, Bundle.HTTPVerb.PATCH), ) } diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt index 40a0936bd9..4d75db5151 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt @@ -20,14 +20,19 @@ import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.sync.DataSource import com.google.android.fhir.sync.ResourceSyncException -import com.google.android.fhir.sync.upload.patch.PerResourcePatchGenerator -import com.google.android.fhir.sync.upload.request.TransactionBundleGenerator -import com.google.android.fhir.sync.upload.request.UploadRequest +import com.google.android.fhir.sync.upload.patch.PatchGenerator +import com.google.android.fhir.sync.upload.request.BundleUploadRequestMapping +import com.google.android.fhir.sync.upload.request.UploadRequestGenerator +import com.google.android.fhir.sync.upload.request.UploadRequestMapping +import com.google.android.fhir.sync.upload.request.UrlUploadRequestMapping +import java.lang.IllegalStateException import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.instance.model.api.IBase +import org.hl7.fhir.instance.model.api.IBaseOperationOutcome import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.DomainResource import org.hl7.fhir.r4.model.OperationOutcome import org.hl7.fhir.r4.model.Resource -import org.hl7.fhir.r4.model.ResourceType import timber.log.Timber /** @@ -38,56 +43,106 @@ import timber.log.Timber * 4. processing the responses from the server and consolidate any changes (i.e. updates resource * IDs). */ -internal class Uploader(private val dataSource: DataSource) { - private val patchGenerator = PerResourcePatchGenerator - private val requestGenerator = TransactionBundleGenerator.getDefault() - +internal class Uploader( + private val dataSource: DataSource, + private val patchGenerator: PatchGenerator, + private val requestGenerator: UploadRequestGenerator, +) { suspend fun upload(localChanges: List<LocalChange>): UploadSyncResult { - val patches = patchGenerator.generate(localChanges) - val requests = requestGenerator.generateUploadRequests(patches) + val mappedPatches = patchGenerator.generate(localChanges) + val mappedRequests = requestGenerator.generateUploadRequests(mappedPatches) val token = LocalChangeToken(localChanges.flatMap { it.token.ids }) - val successfulResponses = mutableListOf<Resource>() + val successfulMappedResponses = mutableListOf<SuccessfulUploadResponseMapping>() - for (uploadRequest in requests) { - when (val result = handleUploadRequest(uploadRequest)) { - is UploadRequestResult.Success -> successfulResponses.add(result.resource) + for (mappedRequest in mappedRequests) { + when (val result = handleUploadRequest(mappedRequest)) { + is UploadRequestResult.Success -> + successfulMappedResponses.addAll(result.successfulUploadResponsMappings) is UploadRequestResult.Failure -> return UploadSyncResult.Failure(result.exception, token) } } + return UploadSyncResult.Success(successfulMappedResponses) + } + + private fun handleUploadResponse( + mappedUploadRequest: UploadRequestMapping, + response: Resource, + ): UploadRequestResult { + val responsesList = + when { + mappedUploadRequest is UrlUploadRequestMapping && response is DomainResource -> + listOf(ResourceUploadResponseMapping(mappedUploadRequest.localChanges, response)) + mappedUploadRequest is BundleUploadRequestMapping && + response is Bundle && + response.type == Bundle.BundleType.TRANSACTIONRESPONSE -> + handleBundleUploadResponse(mappedUploadRequest, response) + else -> + throw IllegalStateException( + "Unknown mapping for request and response. Request Type: ${mappedUploadRequest.javaClass}, Response Type: ${response.resourceType}", + ) + } + return UploadRequestResult.Success(responsesList) + } - return UploadSyncResult.Success(localChanges, successfulResponses) + private fun handleBundleUploadResponse( + mappedUploadRequest: BundleUploadRequestMapping, + bundleResponse: Bundle, + ): List<SuccessfulUploadResponseMapping> { + require(mappedUploadRequest.splitLocalChanges.size == bundleResponse.entry.size) + return mappedUploadRequest.splitLocalChanges.mapIndexed { index, localChanges -> + val bundleEntry = bundleResponse.entry[index] + when { + bundleEntry.hasResource() && bundleEntry.resource is DomainResource -> + ResourceUploadResponseMapping(localChanges, bundleEntry.resource as DomainResource) + bundleEntry.hasResponse() -> + BundleComponentUploadResponseMapping(localChanges, bundleEntry.response) + else -> + throw IllegalStateException( + "Unknown response: $bundleEntry for Bundle Request at index $index", + ) + } + } } - private suspend fun handleUploadRequest(uploadRequest: UploadRequest): UploadRequestResult { + private suspend fun handleUploadRequest( + mappedUploadRequest: UploadRequestMapping, + ): UploadRequestResult { return try { - val response = dataSource.upload(uploadRequest) + val response = dataSource.upload(mappedUploadRequest.generatedRequest) when { - response is Bundle && response.type == Bundle.BundleType.TRANSACTIONRESPONSE -> - UploadRequestResult.Success(response) response is OperationOutcome && response.issue.isNotEmpty() -> UploadRequestResult.Failure( ResourceSyncException( - uploadRequest.resource.resourceType, + mappedUploadRequest.generatedRequest.resource.resourceType, FHIRException(response.issueFirstRep.diagnostics), ), ) + (response is DomainResource || response is Bundle) && + (response !is IBaseOperationOutcome) -> + handleUploadResponse(mappedUploadRequest, response) else -> UploadRequestResult.Failure( ResourceSyncException( - uploadRequest.resource.resourceType, - FHIRException("Unknown response for ${uploadRequest.resource.resourceType}"), + mappedUploadRequest.generatedRequest.resource.resourceType, + FHIRException( + "Unknown response for ${mappedUploadRequest.generatedRequest.resource.resourceType}", + ), ), ) } } catch (e: Exception) { Timber.e(e) - UploadRequestResult.Failure(ResourceSyncException(ResourceType.Bundle, e)) + UploadRequestResult.Failure( + ResourceSyncException(mappedUploadRequest.generatedRequest.resource.resourceType, e), + ) } } private sealed class UploadRequestResult { - data class Success(val resource: Resource) : UploadRequestResult() + data class Success( + val successfulUploadResponsMappings: List<SuccessfulUploadResponseMapping>, + ) : UploadRequestResult() data class Failure(val exception: ResourceSyncException) : UploadRequestResult() } @@ -95,10 +150,24 @@ internal class Uploader(private val dataSource: DataSource) { sealed class UploadSyncResult { data class Success( - val localChanges: List<LocalChange>, - val responseResources: List<Resource>, + val uploadResponses: List<SuccessfulUploadResponseMapping>, ) : UploadSyncResult() data class Failure(val syncError: ResourceSyncException, val localChangeToken: LocalChangeToken) : UploadSyncResult() } + +sealed class SuccessfulUploadResponseMapping( + open val localChanges: List<LocalChange>, + open val output: IBase, +) + +internal data class ResourceUploadResponseMapping( + override val localChanges: List<LocalChange>, + override val output: DomainResource, +) : SuccessfulUploadResponseMapping(localChanges, output) + +internal data class BundleComponentUploadResponseMapping( + override val localChanges: List<LocalChange>, + override val output: Bundle.BundleEntryResponseComponent, +) : SuccessfulUploadResponseMapping(localChanges, output) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PatchGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PatchGenerator.kt index 090e85e2b7..325378d242 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PatchGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PatchGenerator.kt @@ -19,7 +19,8 @@ package com.google.android.fhir.sync.upload.patch import com.google.android.fhir.LocalChange /** - * Generates [Patch]es from [LocalChange]s. + * Generates [Patch]es from [LocalChange]s and output [List<[PatchMapping]>] to keep a mapping of + * the [LocalChange]s to their corresponding generated [Patch] * * INTERNAL ONLY. This interface should NEVER been exposed as an external API because it works * together with other components in the upload package to fulfill a specific upload strategy. @@ -33,7 +34,7 @@ internal interface PatchGenerator { * NOTE: different implementations may have requirements on the size of [localChanges] and output * certain numbers of [Patch]es. */ - fun generate(localChanges: List<LocalChange>): List<Patch> + fun generate(localChanges: List<LocalChange>): List<PatchMapping> } internal object PatchGeneratorFactory { @@ -54,3 +55,13 @@ internal sealed class PatchGeneratorMode { object PerChange : PatchGeneratorMode() } + +/** + * Structure to maintain the mapping between [List<[LocalChange]>] and the [Patch] generated from + * those changes. This class should be used by any implementation of [PatchGenerator] to output the + * [Patch] in this format. + */ +internal data class PatchMapping( + val localChanges: List<LocalChange>, + val generatedPatch: Patch, +) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PerChangePatchGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PerChangePatchGenerator.kt index 9d2840cd2f..153314e118 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PerChangePatchGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PerChangePatchGenerator.kt @@ -25,15 +25,19 @@ import com.google.android.fhir.LocalChange * maintain an audit trail. */ internal object PerChangePatchGenerator : PatchGenerator { - override fun generate(localChanges: List<LocalChange>): List<Patch> = + override fun generate(localChanges: List<LocalChange>): List<PatchMapping> = localChanges.map { - Patch( - resourceType = it.resourceType, - resourceId = it.resourceId, - versionId = it.versionId, - timestamp = it.timestamp, - type = it.type.toPatchType(), - payload = it.payload, + PatchMapping( + localChanges = listOf(it), + generatedPatch = + Patch( + resourceType = it.resourceType, + resourceId = it.resourceId, + versionId = it.versionId, + timestamp = it.timestamp, + type = it.type.toPatchType(), + payload = it.payload, + ), ) } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGenerator.kt index 8d1f6fd466..3931d51517 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGenerator.kt @@ -33,11 +33,18 @@ import org.json.JSONObject */ internal object PerResourcePatchGenerator : PatchGenerator { - override fun generate(localChanges: List<LocalChange>): List<Patch> { + override fun generate(localChanges: List<LocalChange>): List<PatchMapping> { return localChanges .groupBy { it.resourceType to it.resourceId } .values - .mapNotNull { mergeLocalChangesForSingleResource(it) } + .mapNotNull { resourceLocalChanges -> + mergeLocalChangesForSingleResource(resourceLocalChanges)?.let { patch -> + PatchMapping( + localChanges = resourceLocalChanges, + generatedPatch = patch, + ) + } + } } private fun mergeLocalChangesForSingleResource(localChanges: List<LocalChange>): Patch? { diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt index 8753aad164..fa4fc5bc31 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt @@ -16,7 +16,9 @@ package com.google.android.fhir.sync.upload.request +import com.google.android.fhir.LocalChange import com.google.android.fhir.sync.upload.patch.Patch +import com.google.android.fhir.sync.upload.patch.PatchMapping import org.hl7.fhir.r4.model.Bundle /** Generates list of [BundleUploadRequest] of type Transaction [Bundle] from the [Patch]es */ @@ -27,21 +29,38 @@ internal class TransactionBundleGenerator( (patch: Patch, useETagForUpload: Boolean) -> BundleEntryComponentGenerator, ) : UploadRequestGenerator { - override fun generateUploadRequests(patches: List<Patch>): List<BundleUploadRequest> { - return patches.chunked(generatedBundleSize).map { generateBundleRequest(it) } + override fun generateUploadRequests( + mappedPatches: List<PatchMapping>, + ): List<BundleUploadRequestMapping> { + return mappedPatches.chunked(generatedBundleSize).map { patchList -> + generateBundleRequest(patchList).let { mappedBundleRequest -> + BundleUploadRequestMapping( + splitLocalChanges = mappedBundleRequest.first, + generatedRequest = mappedBundleRequest.second, + ) + } + } } - private fun generateBundleRequest(patches: List<Patch>): BundleUploadRequest { + private fun generateBundleRequest( + patches: List<PatchMapping>, + ): Pair<List<List<LocalChange>>, BundleUploadRequest> { + val splitLocalChanges = mutableListOf<List<LocalChange>>() val bundleRequest = Bundle().apply { type = Bundle.BundleType.TRANSACTION patches.forEach { - this.addEntry(getBundleEntryComponentGeneratorForPatch(it, useETagForUpload).getEntry(it)) + splitLocalChanges.add(it.localChanges) + this.addEntry( + getBundleEntryComponentGeneratorForPatch(it.generatedPatch, useETagForUpload) + .getEntry(it.generatedPatch), + ) } } - return BundleUploadRequest( - resource = bundleRequest, - ) + return splitLocalChanges to + BundleUploadRequest( + resource = bundleRequest, + ) } companion object Factory { diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequestGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequestGenerator.kt index d5c344d338..62a4612d97 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequestGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequestGenerator.kt @@ -16,14 +16,23 @@ package com.google.android.fhir.sync.upload.request +import com.google.android.fhir.LocalChange import com.google.android.fhir.sync.upload.patch.Patch +import com.google.android.fhir.sync.upload.patch.PatchMapping import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.codesystems.HttpVerb -/** Generator that generates [UploadRequest]s from the [Patch]es */ +/** + * Generator that generates [UploadRequest]s from the [Patch]es present in the + * [List<[PatchMapping]>]. Any implementation of this generator is expected to output + * [List<[UploadRequestMapping]>] which maps [UploadRequest] to the corresponding [LocalChange]s it + * was generated from. + */ internal interface UploadRequestGenerator { - /** Generates a list of [UploadRequest] from the [Patch]es */ - fun generateUploadRequests(patches: List<Patch>): List<UploadRequest> + /** Generates a list of [UploadRequestMapping] from the [PatchMapping]s */ + fun generateUploadRequests( + mappedPatches: List<PatchMapping>, + ): List<UploadRequestMapping> } /** Mode to decide the type of [UploadRequest] that needs to be generated */ @@ -53,3 +62,17 @@ internal object UploadRequestGeneratorFactory { ) } } + +internal sealed class UploadRequestMapping( + open val generatedRequest: UploadRequest, +) + +internal data class UrlUploadRequestMapping( + val localChanges: List<LocalChange>, + override val generatedRequest: UrlUploadRequest, +) : UploadRequestMapping(generatedRequest) + +internal data class BundleUploadRequestMapping( + val splitLocalChanges: List<List<LocalChange>>, + override val generatedRequest: BundleUploadRequest, +) : UploadRequestMapping(generatedRequest) 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 7898207b24..5fbb174e8a 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 @@ -20,6 +20,7 @@ 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 import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.codesystems.HttpVerb @@ -29,8 +30,15 @@ internal class UrlRequestGenerator( private val getUrlRequestForPatch: (patch: Patch) -> UrlUploadRequest, ) : UploadRequestGenerator { - override fun generateUploadRequests(patches: List<Patch>): List<UrlUploadRequest> = - patches.map { getUrlRequestForPatch(it) } + override fun generateUploadRequests( + mappedPatches: List<PatchMapping>, + ): List<UrlUploadRequestMapping> = + mappedPatches.map { + UrlUploadRequestMapping( + localChanges = it.localChanges, + generatedRequest = getUrlRequestForPatch(it.generatedPatch), + ) + } companion object Factory { diff --git a/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt b/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt index d24acda000..234b1a2ac2 100644 --- a/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt +++ b/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt @@ -36,6 +36,7 @@ import com.google.android.fhir.sync.upload.SyncUploadProgress import com.google.android.fhir.sync.upload.UploadSyncResult import com.google.android.fhir.sync.upload.request.BundleUploadRequest import com.google.android.fhir.sync.upload.request.UploadRequest +import com.google.android.fhir.sync.upload.request.UrlUploadRequest import com.google.common.truth.Truth.assertThat import java.net.SocketTimeoutException import java.time.Instant @@ -110,7 +111,12 @@ object TestDataSourceImpl : DataSource { } override suspend fun upload(request: UploadRequest): Resource { - return Bundle().apply { type = Bundle.BundleType.TRANSACTIONRESPONSE } + return Bundle().apply { + type = Bundle.BundleType.TRANSACTIONRESPONSE + addEntry( + Bundle.BundleEntryComponent().apply { resource = Patient().apply { id = "123" } }, + ) + } } } @@ -183,7 +189,7 @@ object TestFhirEngineImpl : FhirEngine { LocalChange( resourceType = type.name, resourceId = id, - payload = "{ 'resourceType' : 'Patient', 'id' : '123' }", + payload = "{ 'resourceType' : '$type', 'id' : '$id' }", token = LocalChangeToken(listOf()), type = LocalChange.Type.INSERT, timestamp = Instant.now(), @@ -222,3 +228,14 @@ class BundleDataSource(val onPostBundle: suspend (Bundle) -> Resource) : DataSou override suspend fun upload(request: UploadRequest) = onPostBundle((request as BundleUploadRequest).resource) } + +class UrlRequestDataSource(val onUrlRequestSend: suspend (UrlUploadRequest) -> Resource) : + DataSource { + + override suspend fun download(downloadRequest: DownloadRequest): Resource { + TODO("Not yet implemented") + } + + override suspend fun upload(request: UploadRequest) = + onUrlRequestSend((request as UrlUploadRequest)) +} 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 98bfd4c218..ba1d3f9e04 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 @@ -32,6 +32,7 @@ import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.AcceptRemoteConflictResolver import com.google.android.fhir.sync.ResourceSyncException import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.ResourceUploadResponseMapping import com.google.android.fhir.sync.upload.SyncUploadProgress import com.google.android.fhir.sync.upload.UploadSyncResult import com.google.android.fhir.testing.assertResourceEquals @@ -326,8 +327,12 @@ class FhirEngineImplTest { .syncUpload(LocalChangesFetchMode.AllChanges) { localChanges.addAll(it) UploadSyncResult.Success( - it, - listOf(), + listOf( + ResourceUploadResponseMapping( + it, + TEST_PATIENT_1, + ), + ), ) } .collect { emittedProgress.add(it) } diff --git a/engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.kt index 11e7923cc4..f680bc8eb5 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.kt @@ -23,6 +23,7 @@ import androidx.work.ListenableWorker import androidx.work.WorkerParameters import androidx.work.testing.TestListenableWorkerBuilder import com.google.android.fhir.FhirEngine +import com.google.android.fhir.sync.upload.UploadStrategy import com.google.android.fhir.testing.TestDataSourceImpl import com.google.android.fhir.testing.TestDownloadManagerImpl import com.google.android.fhir.testing.TestFailingDatasource @@ -50,6 +51,8 @@ class FhirSyncWorkerTest { override fun getDownloadWorkManager(): DownloadWorkManager = TestDownloadManagerImpl() override fun getConflictResolver() = AcceptRemoteConflictResolver + + override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut } class FailingPeriodicSyncWorker(appContext: Context, workerParams: WorkerParameters) : @@ -62,6 +65,8 @@ class FhirSyncWorkerTest { override fun getDownloadWorkManager(): DownloadWorkManager = TestDownloadManagerImpl() override fun getConflictResolver() = AcceptRemoteConflictResolver + + override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut } class FailingPeriodicSyncWorkerWithoutDataSource( @@ -76,6 +81,8 @@ class FhirSyncWorkerTest { override fun getDataSource(): DataSource? = null override fun getConflictResolver() = AcceptRemoteConflictResolver + + override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut } @Before diff --git a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt index ece81eb07d..eb44c6cf9d 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.ResourceType import org.junit.Before import org.junit.Test @@ -58,9 +59,8 @@ class FhirSynchronizerTest { FhirSynchronizer( ApplicationProvider.getApplicationContext(), TestFhirEngineImpl, - uploader, - downloader, - conflictResolver, + UploadConfiguration(uploader), + DownloadConfiguration(downloader, conflictResolver), ) } @@ -72,7 +72,6 @@ class FhirSynchronizerTest { .thenReturn( UploadSyncResult.Success( listOf(), - listOf(), ), ) @@ -102,7 +101,6 @@ class FhirSynchronizerTest { .thenReturn( UploadSyncResult.Success( listOf(), - listOf(), ), ) diff --git a/engine/src/test/java/com/google/android/fhir/sync/SyncTest.kt b/engine/src/test/java/com/google/android/fhir/sync/SyncTest.kt index 9f81d9c60c..bd25999aa6 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/SyncTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/SyncTest.kt @@ -20,6 +20,7 @@ import android.content.Context import androidx.work.BackoffPolicy import androidx.work.WorkerParameters import com.google.android.fhir.FhirEngine +import com.google.android.fhir.sync.upload.UploadStrategy import com.google.android.fhir.testing.TestDataSourceImpl import com.google.android.fhir.testing.TestDownloadManagerImpl import com.google.android.fhir.testing.TestFhirEngineImpl @@ -43,6 +44,8 @@ class SyncTest { override fun getDownloadWorkManager(): DownloadWorkManager = TestDownloadManagerImpl() override fun getConflictResolver() = AcceptRemoteConflictResolver + + override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut } @Test diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt index d6e1b821ce..2c470d252c 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt @@ -18,9 +18,16 @@ package com.google.android.fhir.sync.upload import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum +import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.impl.entities.LocalChangeEntity +import com.google.android.fhir.logicalId +import com.google.android.fhir.sync.upload.patch.PatchGeneratorFactory +import com.google.android.fhir.sync.upload.patch.PatchGeneratorMode +import com.google.android.fhir.sync.upload.request.UploadRequestGeneratorFactory +import com.google.android.fhir.sync.upload.request.UploadRequestGeneratorMode import com.google.android.fhir.testing.BundleDataSource +import com.google.android.fhir.testing.UrlRequestDataSource import com.google.android.fhir.toLocalChange import com.google.common.truth.Truth.assertThat import java.net.ConnectException @@ -33,6 +40,7 @@ import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.OperationOutcome import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.codesystems.HttpVerb import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -41,19 +49,209 @@ import org.robolectric.RobolectricTestRunner class UploaderTest { @Test - fun `upload should succeed if response is transaction response`() = runTest { + fun `bundle upload for per resource patch should output responses mapped correctly to the local changes`() = + runTest { + val patient1Id = "Patient-001" + val patient2Id = "Patient-002" + val patient2 = Patient().apply { id = patient2Id } + val databaseLocalChanges = + listOf( + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient1Id, + type = LocalChange.Type.INSERT, + payload = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .encodeResourceToString(patient), + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(1)), + ), + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient2Id, + type = LocalChange.Type.INSERT, + payload = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .encodeResourceToString(patient2), + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(2)), + ), + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient1Id, + type = LocalChange.Type.UPDATE, + payload = "[{\"op\":\"replace\",\"path\":\"/name/0/family\",\"value\":\"Nucleus\"}]", + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(3)), + ), + ) + + val updatedPatient1 = + patient.copy().apply { + addName( + HumanName().apply { + addGiven("John") + family = "Nucleus" + }, + ) + } + val result = + Uploader( + BundleDataSource { + Bundle().apply { + type = Bundle.BundleType.TRANSACTIONRESPONSE + addEntry( + Bundle.BundleEntryComponent().apply { resource = updatedPatient1 }, + ) + addEntry( + Bundle.BundleEntryComponent().apply { resource = patient2 }, + ) + } + }, + perResourcePatchGenerator, + bundleUploadRequestGenerator, + ) + .upload(databaseLocalChanges) + + assertThat(result).isInstanceOf(UploadSyncResult.Success::class.java) + with(result as UploadSyncResult.Success) { assertThat(uploadResponses).hasSize(2) } + with(result.uploadResponses[0]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(2) + assertThat(localChanges.all { it.resourceId == patient1Id }).isTrue() + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient1Id) + } + + with(result.uploadResponses[1]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(1) + assertThat(localChanges.all { it.resourceId == patient2Id }).isTrue() + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient2Id) + } + } + + @Test + fun `bundle upload for per change patch should output responses mapped correctly to the local changes`() = + runTest { + val patient1Id = "Patient-001" + val patient2Id = "Patient-002" + val patient2 = Patient().apply { id = patient2Id } + val databaseLocalChanges = + listOf( + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient1Id, + type = LocalChange.Type.INSERT, + payload = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .encodeResourceToString(patient), + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(1)), + ), + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient2Id, + type = LocalChange.Type.INSERT, + payload = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .encodeResourceToString(patient2), + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(2)), + ), + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient1Id, + type = LocalChange.Type.UPDATE, + payload = "[{\"op\":\"replace\",\"path\":\"/name/0/family\",\"value\":\"Nucleus\"}]", + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(3)), + ), + ) + + val updatedPatient1 = + patient.copy().apply { + addName( + HumanName().apply { + addGiven("John") + family = "Nucleus" + }, + ) + } + val result = + Uploader( + BundleDataSource { + Bundle().apply { + type = Bundle.BundleType.TRANSACTIONRESPONSE + addEntry( + Bundle.BundleEntryComponent().apply { resource = patient }, + ) + addEntry( + Bundle.BundleEntryComponent().apply { resource = patient2 }, + ) + addEntry( + Bundle.BundleEntryComponent().apply { resource = updatedPatient1 }, + ) + } + }, + perChangePatchGenerator, + bundleUploadRequestGenerator, + ) + .upload(databaseLocalChanges) + + assertThat(result).isInstanceOf(UploadSyncResult.Success::class.java) + with(result as UploadSyncResult.Success) { assertThat(uploadResponses).hasSize(3) } + with(result.uploadResponses[0]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(1) + assertThat(localChanges[0].resourceId).isEqualTo(patient1Id) + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient1Id) + } + + with(result.uploadResponses[1]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(1) + assertThat(localChanges[0].resourceId).isEqualTo(patient2Id) + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient2Id) + } + + with(result.uploadResponses[2]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(1) + assertThat(localChanges[0].resourceId).isEqualTo(patient1Id) + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient1Id) + } + } + + @Test + fun `bundle upload should fail if bundle response has incorrect size`() = runTest { val result = Uploader( BundleDataSource { Bundle().apply { type = Bundle.BundleType.TRANSACTIONRESPONSE } }, + perResourcePatchGenerator, + bundleUploadRequestGenerator, ) .upload(localChanges) - assertThat(result).isInstanceOf(UploadSyncResult.Success::class.java) - with(result as UploadSyncResult.Success) { assertThat(responseResources).hasSize(1) } + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) } @Test - fun `upload should fail if response is operation outcome with issue`() = runBlocking { + fun `bundle upload should fail if response is operation outcome with issue`() = runBlocking { val result = Uploader( BundleDataSource { @@ -67,6 +265,8 @@ class UploaderTest { ) } }, + perResourcePatchGenerator, + bundleUploadRequestGenerator, ) .upload(localChanges) @@ -74,10 +274,12 @@ class UploaderTest { } @Test - fun `upload should fail if response is empty operation outcome`() = runBlocking { + fun `bundle upload should fail if response is empty operation outcome`() = runBlocking { val result = Uploader( BundleDataSource { OperationOutcome() }, + perResourcePatchGenerator, + bundleUploadRequestGenerator, ) .upload(localChanges) @@ -85,11 +287,13 @@ class UploaderTest { } @Test - fun `upload should fail if response is neither transaction response nor operation outcome`() = + fun `bundle upload should fail if response is neither transaction response nor operation outcome`() = runBlocking { val result = Uploader( BundleDataSource { Bundle().apply { type = Bundle.BundleType.SEARCHSET } }, + perResourcePatchGenerator, + bundleUploadRequestGenerator, ) .upload(localChanges) @@ -97,10 +301,258 @@ class UploaderTest { } @Test - fun `upload should fail if there is connection exception`() = runBlocking { + fun `bundle upload should fail if there is connection exception`() = runBlocking { val result = Uploader( BundleDataSource { throw ConnectException("Failed to connect to server.") }, + perResourcePatchGenerator, + bundleUploadRequestGenerator, + ) + .upload(localChanges) + + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) + } + + @Test + fun `url upload for per resource patch should output responses mapped correctly to the local changes`() = + runTest { + val patient1Id = "Patient-001" + val patient2Id = "Patient-002" + val patient2 = Patient().apply { id = patient2Id } + val databaseLocalChanges = + listOf( + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient1Id, + type = LocalChange.Type.INSERT, + payload = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .encodeResourceToString(patient), + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(1)), + ), + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient2Id, + type = LocalChange.Type.INSERT, + payload = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .encodeResourceToString(patient2), + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(2)), + ), + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient1Id, + type = LocalChange.Type.UPDATE, + payload = "[{\"op\":\"replace\",\"path\":\"/name/0/family\",\"value\":\"Nucleus\"}]", + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(3)), + ), + ) + + val updatedPatient1 = + patient.copy().apply { + addName( + HumanName().apply { + addGiven("John") + family = "Nucleus" + }, + ) + } + val result = + Uploader( + UrlRequestDataSource { + when (it.resource.logicalId) { + patient1Id -> updatedPatient1 + patient2Id -> patient2 + else -> throw IllegalArgumentException("Unknown patient ID") + } + }, + perResourcePatchGenerator, + urlUploadRequestGenerator, + ) + .upload(databaseLocalChanges) + + assertThat(result).isInstanceOf(UploadSyncResult.Success::class.java) + with(result as UploadSyncResult.Success) { assertThat(uploadResponses).hasSize(2) } + with(result.uploadResponses[0]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(2) + assertThat(localChanges.all { it.resourceId == patient1Id }).isTrue() + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient1Id) + } + + with(result.uploadResponses[1]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(1) + assertThat(localChanges.all { it.resourceId == patient2Id }).isTrue() + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient2Id) + } + } + + @Test + fun `url upload for per change patch should output responses mapped correctly to the local changes`() = + runTest { + val patient1Id = "Patient-001" + val patient2Id = "Patient-002" + val patient2 = Patient().apply { id = patient2Id } + val databaseLocalChanges = + listOf( + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient1Id, + type = LocalChange.Type.INSERT, + payload = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .encodeResourceToString(patient), + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(1)), + ), + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient2Id, + type = LocalChange.Type.INSERT, + payload = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .encodeResourceToString(patient2), + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(2)), + ), + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = patient1Id, + type = LocalChange.Type.UPDATE, + payload = "[{\"op\":\"replace\",\"path\":\"/name/0/family\",\"value\":\"Nucleus\"}]", + timestamp = Instant.now(), + versionId = null, + token = LocalChangeToken(listOf(3)), + ), + ) + + val updatedPatient1 = + patient.copy().apply { + addName( + HumanName().apply { + addGiven("John") + family = "Nucleus" + }, + ) + } + val result = + Uploader( + UrlRequestDataSource { + when (it.httpVerb) { + HttpVerb.PUT -> { + when (it.resource.logicalId) { + patient1Id -> updatedPatient1 + patient2Id -> patient2 + else -> throw IllegalArgumentException("Unknown patient ID") + } + } + HttpVerb.PATCH -> updatedPatient1 + else -> throw IllegalArgumentException("Unknown patient ID") + } + }, + perChangePatchGenerator, + urlUploadRequestGenerator, + ) + .upload(databaseLocalChanges) + + assertThat(result).isInstanceOf(UploadSyncResult.Success::class.java) + with(result as UploadSyncResult.Success) { assertThat(uploadResponses).hasSize(3) } + with(result.uploadResponses[0]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(1) + assertThat(localChanges[0].resourceId).isEqualTo(patient1Id) + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient1Id) + } + + with(result.uploadResponses[1]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(1) + assertThat(localChanges[0].resourceId).isEqualTo(patient2Id) + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient2Id) + } + + with(result.uploadResponses[2]) { + assertThat(this).isInstanceOf(ResourceUploadResponseMapping::class.java) + assertThat(localChanges).hasSize(1) + assertThat(localChanges[0].resourceId).isEqualTo(patient1Id) + assertThat(output).isInstanceOf(Patient::class.java) + assertThat((output as Patient).id).isEqualTo(patient1Id) + } + } + + @Test + fun `url upload should fail if response has incorrect resource type`() = runTest { + val result = + Uploader( + UrlRequestDataSource { Bundle().apply { type = Bundle.BundleType.SEARCHSET } }, + perResourcePatchGenerator, + urlUploadRequestGenerator, + ) + .upload(localChanges) + + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) + } + + @Test + fun `url upload should fail if response is operation outcome with issue`() = runBlocking { + val result = + Uploader( + UrlRequestDataSource { + OperationOutcome().apply { + addIssue( + OperationOutcome.OperationOutcomeIssueComponent().apply { + severity = OperationOutcome.IssueSeverity.WARNING + code = OperationOutcome.IssueType.CONFLICT + diagnostics = "The resource has already been updated." + }, + ) + } + }, + perResourcePatchGenerator, + urlUploadRequestGenerator, + ) + .upload(localChanges) + + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) + } + + @Test + fun `url upload should fail if response is empty operation outcome`() = runBlocking { + val result = + Uploader( + UrlRequestDataSource { OperationOutcome() }, + perResourcePatchGenerator, + urlUploadRequestGenerator, + ) + .upload(localChanges) + + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) + } + + @Test + fun `url upload should fail if there is connection exception`() = runBlocking { + val result = + Uploader( + UrlRequestDataSource { throw ConnectException("Failed to connect to server.") }, + perResourcePatchGenerator, + urlUploadRequestGenerator, ) .upload(localChanges) @@ -108,6 +560,27 @@ class UploaderTest { } companion object { + private val perResourcePatchGenerator = + PatchGeneratorFactory.byMode(PatchGeneratorMode.PerResource) + private val perChangePatchGenerator = PatchGeneratorFactory.byMode(PatchGeneratorMode.PerChange) + private val urlUploadRequestGenerator = + UploadRequestGeneratorFactory.byMode( + UploadRequestGeneratorMode.UrlRequest(HttpVerb.PUT, HttpVerb.PATCH), + ) + private val bundleUploadRequestGenerator = + UploadRequestGeneratorFactory.byMode( + UploadRequestGeneratorMode.BundleRequest(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH), + ) + val patient = + Patient().apply { + id = "Patient-001" + addName( + HumanName().apply { + addGiven("John") + family = "Doe" + }, + ) + } val localChanges = listOf( LocalChangeEntity( @@ -119,17 +592,7 @@ class UploaderTest { payload = FhirContext.forCached(FhirVersionEnum.R4) .newJsonParser() - .encodeResourceToString( - Patient().apply { - id = "Patient-001" - addName( - HumanName().apply { - addGiven("John") - family = "Doe" - }, - ) - }, - ), + .encodeResourceToString(patient), timestamp = Instant.now(), ) .toLocalChange() diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt index f111e2d114..2eb6297f1a 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt @@ -55,10 +55,17 @@ class PerResourcePatchGeneratorTest { val patches = PerResourcePatchGenerator.generate(listOf(insertionLocalChange)) with(patches.single()) { - assertThat(type).isEqualTo(Patch.Type.INSERT) - assertThat(resourceId).isEqualTo(patient.logicalId) - assertThat(resourceType).isEqualTo(patient.resourceType.name) - assertThat(payload).isEqualTo(jsonParser.encodeResourceToString(patient)) + with(generatedPatch) { + assertThat(type).isEqualTo(Patch.Type.INSERT) + assertThat(resourceId).isEqualTo(patient.logicalId) + assertThat(resourceType).isEqualTo(patient.resourceType.name) + assertThat(payload).isEqualTo(jsonParser.encodeResourceToString(patient)) + } + + with(localChanges) { + assertThat(this).hasSize(1) + assertThat(this[0]).isEqualTo(insertionLocalChange) + } } } @@ -79,11 +86,18 @@ class PerResourcePatchGeneratorTest { val patches = PerResourcePatchGenerator.generate(listOf(updateLocalChange1)) with(patches.single()) { - assertThat(type).isEqualTo(Patch.Type.UPDATE) - assertThat(resourceId).isEqualTo(remotePatient.logicalId) - assertThat(resourceType).isEqualTo(remotePatient.resourceType.name) - assertThat(versionId).isEqualTo(remoteMeta.versionId) - assertJsonArrayEqualsIgnoringOrder(JSONArray(payload), updatePatch) + with(generatedPatch) { + assertThat(type).isEqualTo(Patch.Type.UPDATE) + assertThat(resourceId).isEqualTo(remotePatient.logicalId) + assertThat(resourceType).isEqualTo(remotePatient.resourceType.name) + assertThat(versionId).isEqualTo(remoteMeta.versionId) + assertJsonArrayEqualsIgnoringOrder(JSONArray(payload), updatePatch) + } + + with(localChanges) { + assertThat(this).hasSize(1) + assertThat(this[0]).isEqualTo(updateLocalChange1) + } } } @@ -101,11 +115,18 @@ class PerResourcePatchGeneratorTest { val patches = PerResourcePatchGenerator.generate(listOf(deleteLocalChange)) with(patches.single()) { - assertThat(type).isEqualTo(Patch.Type.DELETE) - assertThat(resourceId).isEqualTo(remotePatient.logicalId) - assertThat(resourceType).isEqualTo(remotePatient.resourceType.name) - assertThat(versionId).isEqualTo(remoteMeta.versionId) - assertThat(payload).isEmpty() + with(generatedPatch) { + assertThat(type).isEqualTo(Patch.Type.DELETE) + assertThat(resourceId).isEqualTo(remotePatient.logicalId) + assertThat(resourceType).isEqualTo(remotePatient.resourceType.name) + assertThat(versionId).isEqualTo(remoteMeta.versionId) + assertThat(payload).isEmpty() + } + + with(localChanges) { + assertThat(this).hasSize(1) + assertThat(this[0]).isEqualTo(deleteLocalChange) + } } } @@ -121,10 +142,17 @@ class PerResourcePatchGeneratorTest { PerResourcePatchGenerator.generate(listOf(insertionLocalChange, updateLocalChange)) with(patches.single()) { - assertThat(type).isEqualTo(Patch.Type.INSERT) - assertThat(resourceId).isEqualTo(patient.logicalId) - assertThat(resourceType).isEqualTo(patient.resourceType.name) - assertThat(payload).isEqualTo(patientString) + with(generatedPatch) { + assertThat(type).isEqualTo(Patch.Type.INSERT) + assertThat(resourceId).isEqualTo(patient.logicalId) + assertThat(resourceType).isEqualTo(patient.resourceType.name) + assertThat(payload).isEqualTo(patientString) + } + + with(localChanges) { + assertThat(this).hasSize(2) + assertThat(this).containsExactly(insertionLocalChange, updateLocalChange) + } } } @@ -271,11 +299,18 @@ class PerResourcePatchGeneratorTest { val patches = PerResourcePatchGenerator.generate(listOf(updateLocalChange1, updateLocalChange2)) with(patches.single()) { - assertThat(type).isEqualTo(Patch.Type.UPDATE) - assertThat(resourceId).isEqualTo(remotePatient.logicalId) - assertThat(resourceType).isEqualTo(remotePatient.resourceType.name) - assertThat(versionId).isEqualTo(remoteMeta.versionId) - assertJsonArrayEqualsIgnoringOrder(JSONArray(payload), updatePatch) + with(generatedPatch) { + assertThat(type).isEqualTo(Patch.Type.UPDATE) + assertThat(resourceId).isEqualTo(remotePatient.logicalId) + assertThat(resourceType).isEqualTo(remotePatient.resourceType.name) + assertThat(versionId).isEqualTo(remoteMeta.versionId) + assertJsonArrayEqualsIgnoringOrder(JSONArray(payload), updatePatch) + } + + with(localChanges) { + assertThat(size).isEqualTo(2) + assertThat(this).containsExactly(updateLocalChange1, updateLocalChange2) + } } } @@ -302,11 +337,18 @@ class PerResourcePatchGeneratorTest { ) with(patches.single()) { - assertThat(type).isEqualTo(Patch.Type.DELETE) - assertThat(resourceId).isEqualTo(remotePatient.logicalId) - assertThat(resourceType).isEqualTo(remotePatient.resourceType.name) - assertThat(versionId).isEqualTo(remoteMeta.versionId) - assertThat(payload).isEmpty() + with(generatedPatch) { + assertThat(type).isEqualTo(Patch.Type.DELETE) + assertThat(resourceId).isEqualTo(remotePatient.logicalId) + assertThat(resourceType).isEqualTo(remotePatient.resourceType.name) + assertThat(versionId).isEqualTo(remoteMeta.versionId) + assertThat(payload).isEmpty() + } + + with(localChanges) { + assertThat(size).isEqualTo(3) + assertThat(this).containsExactly(updateLocalChange1, updateLocalChange2, deleteLocalChange) + } } } diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/request/IndividualGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/request/IndividualGeneratorTest.kt index 37d5baf49f..0e02b6261a 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/request/IndividualGeneratorTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/request/IndividualGeneratorTest.kt @@ -16,16 +16,14 @@ 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.sync.upload.patch.Patch +import com.google.android.fhir.sync.upload.patch.PatchMapping +import com.google.android.fhir.sync.upload.request.RequestGeneratorTestUtils.deleteLocalChange +import com.google.android.fhir.sync.upload.request.RequestGeneratorTestUtils.insertionLocalChange +import com.google.android.fhir.sync.upload.request.RequestGeneratorTestUtils.toPatch +import com.google.android.fhir.sync.upload.request.RequestGeneratorTestUtils.updateLocalChange import com.google.common.truth.Truth.assertThat -import java.time.Instant import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Binary -import org.hl7.fhir.r4.model.HumanName -import org.hl7.fhir.r4.model.Patient -import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.codesystems.HttpVerb import org.junit.Test import org.junit.runner.RunWith @@ -34,8 +32,6 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class IndividualGeneratorTest { - private val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() - @Test fun `should return empty list if there are no local changes`() = runTest { val generator = UrlRequestGenerator.getDefault() @@ -46,179 +42,99 @@ class IndividualGeneratorTest { @Test fun `should create a POST request for insert`() = runTest { val generator = UrlRequestGenerator.getGenerator(HttpVerb.POST, HttpVerb.PATCH) + val patchOutput = + PatchMapping( + localChanges = listOf(insertionLocalChange), + generatedPatch = insertionLocalChange.toPatch(), + ) val requests = generator.generateUploadRequests( - listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-001", - type = Patch.Type.INSERT, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-001" - addName( - HumanName().apply { - addGiven("John") - family = "Doe" - }, - ) - }, - ), - timestamp = Instant.now(), - ), - ), + listOf(patchOutput), ) with(requests.single()) { - assertThat(httpVerb).isEqualTo(HttpVerb.POST) - assertThat(url).isEqualTo("Patient") + with(generatedRequest) { + assertThat(httpVerb).isEqualTo(HttpVerb.POST) + assertThat(url).isEqualTo("Patient") + } + + assertThat(localChanges).isEqualTo(patchOutput.localChanges) } } @Test fun `should create a PUT request for insert`() = runTest { val generator = UrlRequestGenerator.getDefault() + val patchOutput = + PatchMapping( + localChanges = listOf(insertionLocalChange), + generatedPatch = insertionLocalChange.toPatch(), + ) val requests = generator.generateUploadRequests( - listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-001", - type = Patch.Type.INSERT, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-001" - addName( - HumanName().apply { - addGiven("John") - family = "Doe" - }, - ) - }, - ), - timestamp = Instant.now(), - ), - ), + listOf(patchOutput), ) with(requests.single()) { - assertThat(httpVerb).isEqualTo(HttpVerb.PUT) - assertThat(url).isEqualTo("Patient/Patient-001") + with(generatedRequest) { + assertThat(httpVerb).isEqualTo(HttpVerb.PUT) + assertThat(url).isEqualTo("Patient/Patient-001") + } + assertThat(localChanges).isEqualTo(patchOutput.localChanges) } } @Test fun `should create a PATCH request for update`() = runTest { - val patches = - listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-002", - type = Patch.Type.UPDATE, - payload = - "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]", - timestamp = Instant.now(), - ), + val patchOutput = + PatchMapping( + localChanges = listOf(updateLocalChange), + generatedPatch = updateLocalChange.toPatch(), ) val generator = UrlRequestGenerator.Factory.getDefault() - val requests = generator.generateUploadRequests(patches) + val requests = generator.generateUploadRequests(listOf(patchOutput)) with(requests.single()) { - assertThat(requests.size).isEqualTo(1) - assertThat(httpVerb).isEqualTo(HttpVerb.PATCH) - assertThat(url).isEqualTo("Patient/Patient-002") - assertThat((resource as Binary).data.toString(Charsets.UTF_8)) - .isEqualTo( - "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]", - ) + with(generatedRequest) { + assertThat(requests.size).isEqualTo(1) + assertThat(httpVerb).isEqualTo(HttpVerb.PATCH) + assertThat(url).isEqualTo("Patient/Patient-001") + assertThat((resource as Binary).data.toString(Charsets.UTF_8)) + .isEqualTo( + "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]", + ) + } + assertThat(localChanges).isEqualTo(patchOutput.localChanges) } } @Test fun `should create a DELETE request for delete`() = runTest { - val patches = - listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-001", - type = Patch.Type.DELETE, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-001" - addName( - HumanName().apply { - addGiven("John") - family = "Doe" - }, - ) - }, - ), - timestamp = Instant.now(), - ), + val patchOutput = + PatchMapping( + localChanges = listOf(deleteLocalChange), + generatedPatch = deleteLocalChange.toPatch(), ) val generator = UrlRequestGenerator.Factory.getDefault() - val requests = generator.generateUploadRequests(patches) + val requests = generator.generateUploadRequests(listOf(patchOutput)) with(requests.single()) { - assertThat(httpVerb).isEqualTo(HttpVerb.DELETE) - assertThat(url).isEqualTo("Patient/Patient-001") + with(generatedRequest) { + assertThat(httpVerb).isEqualTo(HttpVerb.DELETE) + assertThat(url).isEqualTo("Patient/Patient-001") + } + assertThat(localChanges).isEqualTo(patchOutput.localChanges) } } @Test fun `should return multiple requests in order`() = runTest { - val patches = - listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-001", - type = Patch.Type.INSERT, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-001" - addName( - HumanName().apply { - addGiven("John") - family = "Doe" - }, - ) - }, - ), - timestamp = Instant.now(), - ), - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-002", - type = Patch.Type.UPDATE, - payload = - "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]", - timestamp = Instant.now(), - ), - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-003", - type = Patch.Type.DELETE, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-003" - addName( - HumanName().apply { - addGiven("John") - family = "Roe" - }, - ) - }, - ), - timestamp = Instant.now(), - ), - ) + val patchOutputList = + listOf(insertionLocalChange, updateLocalChange, deleteLocalChange).map { + PatchMapping(listOf(it), it.toPatch()) + } val generator = UrlRequestGenerator.Factory.getDefault() - val result = generator.generateUploadRequests(patches) + val result = generator.generateUploadRequests(patchOutputList) assertThat(result).hasSize(3) - assertThat(result.map { it.httpVerb }) + assertThat(result.map { it.generatedRequest.httpVerb }) .containsExactly(HttpVerb.PUT, HttpVerb.PATCH, HttpVerb.DELETE) .inOrder() } diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/request/RequestGeneratorTestUtils.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/request/RequestGeneratorTestUtils.kt new file mode 100644 index 0000000000..6f8bc949dd --- /dev/null +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/request/RequestGeneratorTestUtils.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2023 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.sync.upload.request + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import com.google.android.fhir.LocalChange +import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.sync.upload.patch.Patch +import java.time.Instant +import org.hl7.fhir.r4.model.HumanName +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.ResourceType + +object RequestGeneratorTestUtils { + + fun LocalChange.toPatch() = + Patch( + resourceType = resourceType, + resourceId = resourceId, + versionId = versionId, + timestamp = timestamp, + payload = payload, + type = Patch.Type.from(type.value), + ) + + val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + val insertionLocalChange = + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = "Patient-001", + type = LocalChange.Type.INSERT, + payload = + jsonParser.encodeResourceToString( + Patient().apply { + id = "Patient-001" + addName( + HumanName().apply { + addGiven("John") + family = "Doe" + }, + ) + }, + ), + timestamp = Instant.now(), + token = LocalChangeToken(listOf(1L)), + ) + val updateLocalChange = + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = "Patient-001", + type = LocalChange.Type.UPDATE, + payload = "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]", + timestamp = Instant.now(), + token = LocalChangeToken(listOf(2L)), + versionId = "v-p002-01", + ) + val deleteLocalChange = + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = "Patient-001", + type = LocalChange.Type.DELETE, + payload = + jsonParser.encodeResourceToString( + Patient().apply { + id = "Patient-001" + addName( + HumanName().apply { + addGiven("John") + family = "Doe" + }, + ) + }, + ), + timestamp = Instant.now(), + token = LocalChangeToken(listOf(2L)), + versionId = "v-p003-01", + ) +} diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt index d21e30a4c6..6319ac0eee 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt @@ -18,14 +18,17 @@ 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.db.impl.dao.diff -import com.google.android.fhir.sync.upload.patch.Patch +import com.google.android.fhir.LocalChange +import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.sync.upload.patch.PatchMapping +import com.google.android.fhir.sync.upload.request.RequestGeneratorTestUtils.deleteLocalChange +import com.google.android.fhir.sync.upload.request.RequestGeneratorTestUtils.insertionLocalChange +import com.google.android.fhir.sync.upload.request.RequestGeneratorTestUtils.toPatch +import com.google.android.fhir.sync.upload.request.RequestGeneratorTestUtils.updateLocalChange import com.google.common.truth.Truth.assertThat import java.time.Instant import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.HumanName -import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.ResourceType import org.junit.Assert.assertThrows import org.junit.Test @@ -46,85 +49,24 @@ class TransactionBundleGeneratorTest { @Test fun `generateUploadRequests() should return single Transaction Bundle with 3 entries`() = runBlocking { - val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val patches = - listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-001", - type = Patch.Type.INSERT, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-001" - addName( - HumanName().apply { - addGiven("John") - family = "Doe" - }, - ) - }, - ), - timestamp = Instant.now(), - ), - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-002", - type = Patch.Type.UPDATE, - payload = - diff( - jsonParser, - Patient().apply { - id = "Patient-002" - addName( - HumanName().apply { - addGiven("Jane") - family = "Doe" - }, - ) - }, - Patient().apply { - id = "Patient-002" - addName( - HumanName().apply { - addGiven("Janet") - family = "Doe" - }, - ) - }, - ) - .toString(), - timestamp = Instant.now(), - ), - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-003", - type = Patch.Type.DELETE, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-003" - addName( - HumanName().apply { - addGiven("John") - family = "Roe" - }, - ) - }, - ), - timestamp = Instant.now(), - ), - ) + listOf(insertionLocalChange, updateLocalChange, deleteLocalChange).map { + PatchMapping(listOf(it), it.toPatch()) + } val generator = TransactionBundleGenerator.Factory.getDefault() val result = generator.generateUploadRequests(patches) assertThat(result).hasSize(1) - val bundleUploadRequest = result[0] - assertThat(bundleUploadRequest.resource.type).isEqualTo(Bundle.BundleType.TRANSACTION) - assertThat(bundleUploadRequest.resource.entry).hasSize(3) - assertThat(bundleUploadRequest.resource.entry.map { it.request.method }) - .containsExactly(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH, Bundle.HTTPVerb.DELETE) - .inOrder() + with(result[0].generatedRequest.resource) { + assertThat(type).isEqualTo(Bundle.BundleType.TRANSACTION) + assertThat(entry).hasSize(3) + assertThat(entry.map { it.request.method }) + .containsExactly(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH, Bundle.HTTPVerb.DELETE) + .inOrder() + } + + assertThat(result[0].splitLocalChanges).hasSize(3) + assertThat(result[0].splitLocalChanges.all { it.size == 1 }).isTrue() } @Test @@ -132,75 +74,9 @@ class TransactionBundleGeneratorTest { runBlocking { val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val patches = - listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-001", - type = Patch.Type.INSERT, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-001" - addName( - HumanName().apply { - addGiven("John") - family = "Doe" - }, - ) - }, - ), - timestamp = Instant.now(), - ), - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-002", - type = Patch.Type.UPDATE, - payload = - diff( - jsonParser, - Patient().apply { - id = "Patient-002" - addName( - HumanName().apply { - addGiven("Jane") - family = "Doe" - }, - ) - }, - Patient().apply { - id = "Patient-002" - addName( - HumanName().apply { - addGiven("Janet") - family = "Doe" - }, - ) - }, - ) - .toString(), - versionId = "v-p002-01", - timestamp = Instant.now(), - ), - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-003", - type = Patch.Type.DELETE, - payload = - jsonParser.encodeResourceToString( - Patient().apply { - id = "Patient-003" - addName( - HumanName().apply { - addGiven("John") - family = "Roe" - }, - ) - }, - ), - versionId = "v-p003-01", - timestamp = Instant.now(), - ), - ) + listOf(insertionLocalChange, updateLocalChange, deleteLocalChange).map { + PatchMapping(listOf(it), it.toPatch()) + } val generator = TransactionBundleGenerator.Factory.getGenerator( Bundle.HTTPVerb.PUT, @@ -208,90 +84,113 @@ class TransactionBundleGeneratorTest { 1, true, ) - val result = generator.generateUploadRequests(patches) - - // Exactly 3 Requests are generated - assertThat(result).hasSize(3) - // Each Request is of type Bundle - assertThat(result.all { it.resource.type == Bundle.BundleType.TRANSACTION }).isTrue() - // Each Bundle has exactly 1 entry - assertThat(result.all { it.resource.entry.size == 1 }).isTrue() - assertThat(result.map { it.resource.entry.first().request.method }) - .containsExactly(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH, Bundle.HTTPVerb.DELETE) - .inOrder() - assertThat(result.map { it.resource.entry.first().request.ifMatch }) - .containsExactly(null, "W/\"v-p002-01\"", "W/\"v-p003-01\"") - .inOrder() + with(generator.generateUploadRequests(patches)) { + // Exactly 3 Requests are generated + assertThat(this).hasSize(3) + // Each Request is of type Bundle + assertThat(all { it.generatedRequest.resource.type == Bundle.BundleType.TRANSACTION }) + .isTrue() + // Each Bundle has exactly 1 entry + assertThat(all { it.generatedRequest.resource.entry.size == 1 }).isTrue() + assertThat(map { it.generatedRequest.resource.entry.first().request.method }) + .containsExactly(Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH, Bundle.HTTPVerb.DELETE) + .inOrder() + assertThat(map { it.generatedRequest.resource.entry.first().request.ifMatch }) + .containsExactly(null, "W/\"v-p002-01\"", "W/\"v-p003-01\"") + .inOrder() + // Each Bundle request is mapped to 1 LocalChange + assertThat(all { it.splitLocalChanges.size == 1 }).isTrue() + assertThat(all { it.splitLocalChanges[0].size == 1 }).isTrue() + } } @Test fun `generate() should return Bundle Entry without if-match when useETagForUpload is false`() = runBlocking { + val localChange = + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = "Patient-002", + type = LocalChange.Type.UPDATE, + payload = "[]", + versionId = "patient-002-version-1", + timestamp = Instant.now(), + token = LocalChangeToken(listOf(1L)), + ) val patches = listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-002", - type = Patch.Type.UPDATE, - payload = "[]", - versionId = "patient-002-version-1", - timestamp = Instant.now(), + PatchMapping( + localChanges = listOf(localChange), + generatedPatch = localChange.toPatch(), ), ) val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = false) val result = generator.generateUploadRequests(patches) - assertThat(result.first().resource.entry.first().request.ifMatch).isNull() + assertThat(result.first().generatedRequest.resource.entry.first().request.ifMatch).isNull() + assertThat(result.first().splitLocalChanges.size).isEqualTo(1) + assertThat(result.first().splitLocalChanges[0].size).isEqualTo(1) } @Test fun `generate() should return Bundle Entry with if-match when useETagForUpload is true`() = runBlocking { + val localChange = + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = "Patient-002", + type = LocalChange.Type.UPDATE, + payload = "[]", + versionId = "patient-002-version-1", + timestamp = Instant.now(), + token = LocalChangeToken(listOf(1L)), + ) val patches = listOf( - Patch( - resourceType = ResourceType.Patient.name, - resourceId = "Patient-002", - type = Patch.Type.UPDATE, - payload = "[]", - versionId = "patient-002-version-1", - timestamp = Instant.now(), + PatchMapping( + localChanges = listOf(localChange), + generatedPatch = localChange.toPatch(), ), ) val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = true) val result = generator.generateUploadRequests(patches) - assertThat(result.first().resource.entry.first().request.ifMatch) + assertThat(result.first().generatedRequest.resource.entry.first().request.ifMatch) .isEqualTo("W/\"patient-002-version-1\"") + assertThat(result.first().splitLocalChanges.size).isEqualTo(1) + assertThat(result.first().splitLocalChanges[0].size).isEqualTo(1) } @Test fun `generate() should return Bundle Entry without if-match when the LocalChangeEntity has no versionId`() = runBlocking { - val patches = + val localChanges = listOf( - Patch( + LocalChange( resourceType = ResourceType.Patient.name, resourceId = "Patient-002", - type = Patch.Type.UPDATE, + type = LocalChange.Type.UPDATE, payload = "[]", versionId = "", timestamp = Instant.now(), + token = LocalChangeToken(listOf(1L)), ), - Patch( + LocalChange( resourceType = ResourceType.Patient.name, resourceId = "Patient-003", - type = Patch.Type.UPDATE, + type = LocalChange.Type.UPDATE, payload = "[]", versionId = null, timestamp = Instant.now(), + token = LocalChangeToken(listOf(2L)), ), ) + val patches = localChanges.map { PatchMapping(listOf(it), it.toPatch()) } val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = true) val result = generator.generateUploadRequests(patches) - assertThat(result.first().resource.entry[0].request.ifMatch).isNull() - assertThat(result.first().resource.entry[1].request.ifMatch).isNull() + assertThat(result.first().generatedRequest.resource.entry[0].request.ifMatch).isNull() + assertThat(result.first().generatedRequest.resource.entry[1].request.ifMatch).isNull() } @Test From 92cbffa73722ec9a1af6e4d8727c74f8126e2581 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 12:32:45 +0000 Subject: [PATCH 60/69] Bump actions/upload-artifact from 3 to 4 (#2376) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cef0fbf978..191fd8401c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,7 +80,7 @@ jobs: - name: Release artifacts to local repo run: ./gradlew publishReleasePublicationToCIRepository --scan - name: Upload maven repo - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: maven-repository path: build/ci-repo @@ -92,7 +92,7 @@ jobs: # Upload the build dir for all the modules for diagnosis - name: Upload build dir if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: build path: build.zip From ea9879fb683c8afd6fa84276504a95ec374f2596 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 15:13:38 +0000 Subject: [PATCH 61/69] Bump com.fasterxml.jackson.core:jackson-core from 2.15.3 to 2.16.0 (#2374) Bumps [com.fasterxml.jackson.core:jackson-core](https://github.com/FasterXML/jackson-core) from 2.15.3 to 2.16.0. - [Release notes](https://github.com/FasterXML/jackson-core/releases) - [Commits](https://github.com/FasterXML/jackson-core/compare/jackson-core-2.15.3...jackson-core-2.16.0) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.core:jackson-core dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2462bb8093..16f5bbdf50 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,7 +31,7 @@ if (kokoroRun == true) { // NECESSARY force of the Jackson to run generateSearchParams in the new version of HAPI (6.8) buildscript { dependencies { - classpath("com.fasterxml.jackson.core:jackson-core:2.15.3") + classpath("com.fasterxml.jackson.core:jackson-core:2.16.0") classpath("com.fasterxml.jackson.core:jackson-annotations:2.15.3") classpath("com.fasterxml.jackson.core:jackson-databind:2.16.0") classpath("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.16.0") From 42e4af6d3305bb9380a3c635934dc3a4d4766f27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 18:34:55 +0000 Subject: [PATCH 62/69] Bump github/codeql-action from 2 to 3 (#2377) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v2...v3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 1f52273e93..925aff49bc 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -58,7 +58,7 @@ jobs: distribution: temurin - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} @@ -69,6 +69,6 @@ jobs: run: ./gradlew --scan --full-stacktrace -Dorg.gradle.dependency.verification=off compileDebugAndroidTestSources - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" From 5848184910b03f48e72f7ea5630fcf76072e5852 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 20:43:27 +0000 Subject: [PATCH 63/69] Bump ca.uhn.hapi.fhir:hapi-fhir-structures-r4 from 6.8.0 to 6.10.0 (#2375) Bumps ca.uhn.hapi.fhir:hapi-fhir-structures-r4 from 6.8.0 to 6.10.0. --- updated-dependencies: - dependency-name: ca.uhn.hapi.fhir:hapi-fhir-structures-r4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- common/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 9a8ff8bf57..4dec42c493 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -18,6 +18,6 @@ dependencies { implementation("com.spotify.ruler:ruler-gradle-plugin:1.4.0") - implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.8.0") + implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.10.0") implementation("com.squareup:kotlinpoet:1.15.3") } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 323e06de02..548e463c07 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -29,7 +29,7 @@ configurations { dependencies { // REVERT to DEPENDENCIES LATER - api("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.8.0") + api("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.10.0") api("ca.uhn.hapi.fhir:hapi-fhir-caching-guava:6.10.0") implementation(Dependencies.fhirUcum) From 977ecfac11a5fc7dc283b6f1911cb800af9193fb Mon Sep 17 00:00:00 2001 From: Maimoona Kausar <4829880+maimoonak@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:18:58 +0500 Subject: [PATCH 64/69] Add fhirpath supplements (#2346) * Add fhirpath supplements * Add unit tests * Update datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt Co-authored-by: Jing Tang <jingtang@google.com> * Update datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt Co-authored-by: Jing Tang <jingtang@google.com> * Resolve feedback and test issues * Fix compile issue * Update datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt Co-authored-by: Jing Tang <jingtang@google.com> * Rename tests --------- Co-authored-by: Jing Tang <jingtang@google.com> --- .../enablement/EnablementEvaluator.kt | 16 +- .../EnabledAnswerOptionsEvaluator.kt | 4 +- .../fhir/datacapture/extensions/MoreTypes.kt | 4 +- .../fhirpath/ExpressionEvaluator.kt | 68 +++++--- .../fhir/datacapture/fhirpath/FhirPathUtil.kt | 55 ++++++- .../datacapture/mapping/ResourceMapper.kt | 14 +- .../datacapture/QuestionnaireViewModelTest.kt | 106 +++++++++++- .../enablement/EnablementEvaluatorTest.kt | 140 +++++++++++++++- .../fhirpath/ExpressionEvaluatorTest.kt | 153 ++++++++++++++++++ 9 files changed, 511 insertions(+), 49 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/enablement/EnablementEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/enablement/EnablementEvaluator.kt index 23a0431667..f4a6a403c3 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/enablement/EnablementEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/enablement/EnablementEvaluator.kt @@ -20,7 +20,7 @@ import com.google.android.fhir.compareTo import com.google.android.fhir.datacapture.extensions.allItems import com.google.android.fhir.datacapture.extensions.enableWhenExpression import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator -import com.google.android.fhir.datacapture.fhirpath.evaluateToBoolean +import com.google.android.fhir.datacapture.fhirpath.convertToBoolean import com.google.android.fhir.equals import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -138,16 +138,12 @@ internal class EnablementEvaluator( // Evaluate `enableWhenExpression`. if (enableWhenExpression != null && enableWhenExpression.hasExpression()) { - val contextMap = - expressionEvaluator.extractDependentVariables( - questionnaireItem.enableWhenExpression!!, + return convertToBoolean( + expressionEvaluator.evaluateExpression( questionnaireItem, - ) - return evaluateToBoolean( - questionnaireResponse, - questionnaireResponseItem, - enableWhenExpression.expression, - contextMap, + questionnaireResponseItem, + questionnaireItem.enableWhenExpression!!, + ), ) } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt index 73a46304cc..1a971375d4 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt @@ -24,7 +24,7 @@ import com.google.android.fhir.datacapture.extensions.extractAnswerOptions import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.isXFhirQuery import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator -import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine +import com.google.android.fhir.datacapture.fhirpath.convertToBoolean import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent @@ -250,7 +250,7 @@ internal class EnabledAnswerOptionsEvaluator( val (expression, toggleOptions) = it val evaluationResult = if (expression.isFhirPath) { - fhirPathEngine.convertToBoolean( + convertToBoolean( expressionEvaluator.evaluateExpression( item, questionnaireResponseItem, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index 7df499a385..ab5120d8bc 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -18,7 +18,7 @@ package com.google.android.fhir.datacapture.extensions import android.content.Context import com.google.android.fhir.datacapture.R -import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine +import com.google.android.fhir.datacapture.fhirpath.evaluateToBase import com.google.android.fhir.datacapture.views.factories.localDate import com.google.android.fhir.datacapture.views.factories.localTime import com.google.android.fhir.getLocalizedText @@ -130,7 +130,7 @@ fun Type.valueOrCalculateValue(): Type { .firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } ?.let { extension -> val expression = (extension.value as Expression).expression - fhirPathEngine.evaluate(this, expression).singleOrNull()?.let { it as Type } + evaluateToBase(this, expression).singleOrNull()?.let { it as Type } } ?: this } else { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt index 6fc5840384..33abca3a51 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt @@ -24,6 +24,7 @@ import com.google.android.fhir.datacapture.extensions.variableExpressions import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.ExpressionNode import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -80,6 +81,20 @@ internal class ExpressionEvaluator( */ private val xFhirQueryEnhancementRegex = Regex("\\{\\{(.*?)\\}\\}") + /** + * Variable %questionnaire corresponds to the Questionnaire resource into + * QuestionnaireResponse.questionnaire element. + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + */ + private val questionnaireFhirPathSupplement = "questionnaire" + + /** + * Variable %qitem refer to Questionnaire.item that corresponds to context + * QuestionnaireResponse.item. It is only valid for FHIRPath expressions defined within a + * Questionnaire item. https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + */ + private val questionnaireItemFhirPathSupplement = "qItem" + /** Detects if any item into list is referencing a dependent item in its calculated expression */ internal fun detectExpressionCyclicDependency(items: List<QuestionnaireItemComponent>) { items @@ -112,12 +127,11 @@ internal class ExpressionEvaluator( expression: Expression, ): List<Base> { val appContext = extractDependentVariables(expression, questionnaireItem) - return fhirPathEngine.evaluate( - appContext, + return evaluateToBase( questionnaireResponse, - null, questionnaireResponseItem, expression.expression, + appContext, ) } @@ -202,7 +216,7 @@ internal class ExpressionEvaluator( * @param variablesMap the [Map<String, Base>] of variables, the default value is empty map is * defined */ - internal fun extractDependentVariables( + private fun extractDependentVariables( expression: Expression, questionnaireItem: QuestionnaireItemComponent, variablesMap: MutableMap<String, Base?> = mutableMapOf(), @@ -217,7 +231,10 @@ internal class ExpressionEvaluator( ) } } - return variablesMap + return variablesMap.apply { + put(questionnaireFhirPathSupplement, questionnaire) + put(questionnaireItemFhirPathSupplement, questionnaireItem) + } } /** @@ -275,7 +292,9 @@ internal class ExpressionEvaluator( val fhirPathsEvaluatedPairs = questionnaireLaunchContextMap + ?.toMutableMap() .takeIf { !it.isNullOrEmpty() } + ?.also { it.put(questionnaireFhirPathSupplement, questionnaire) } ?.let { evaluateXFhirEnhancement(expression, it) } ?: emptySequence() @@ -304,19 +323,12 @@ internal class ExpressionEvaluator( .findAll(expression.expression) .map { it.groupValues } .map { (fhirPathWithParentheses, fhirPath) -> - // TODO(omarismail94): See if FHIRPathEngine.check() can be used to distinguish invalid - // expression vs an expression that is valid, but does not return one resource only. - val expressionNode = fhirPathEngine.parse(fhirPath) - val resourceType = - expressionNode.constant?.primitiveValue()?.substring(1) - ?: expressionNode.name?.lowercase() + val expressionNode = extractExpressionNode(fhirPath) val evaluatedResult = - fhirPathEngine.evaluateToString( - launchContextMap, - null, - null, - launchContextMap[resourceType], - expressionNode, + evaluateToString( + expression = expressionNode, + data = launchContextMap[extractResourceType(expressionNode)], + contextMap = launchContextMap, ) // If the result of evaluating the FHIRPath expressions is an invalid query, it returns @@ -425,13 +437,11 @@ internal class ExpressionEvaluator( "Unsupported expression language, language should be text/fhirpath" } - fhirPathEngine - .evaluate( - /* appContext= */ dependentVariables, - /* focusResource= */ questionnaireResponse, - /* rootResource= */ null, - /* base= */ null, - /* path= */ expression.expression, + evaluateToBase( + questionnaireResponse = questionnaireResponse, + questionnaireResponseItem = null, + expression = expression.expression, + contextMap = dependentVariables, ) .firstOrNull() } catch (exception: FHIRException) { @@ -440,5 +450,15 @@ internal class ExpressionEvaluator( } } +/** + * Extract [ResourceType] string representation from constant or name property of given + * [ExpressionNode]. + */ +private fun extractResourceType(expressionNode: ExpressionNode): String? { + // TODO(omarismail94): See if FHIRPathEngine.check() can be used to distinguish invalid + // expression vs an expression that is valid, but does not return one resource only. + return expressionNode.constant?.primitiveValue()?.substring(1) ?: expressionNode.name?.lowercase() +} + /** Pair of a [Questionnaire.QuestionnaireItemComponent] with its evaluated answers */ internal typealias ItemToAnswersPair = Pair<QuestionnaireItemComponent, List<Type>> diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt index 04aff30392..4c7d626913 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt @@ -20,12 +20,14 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.ExpressionNode import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Type import org.hl7.fhir.r4.utils.FHIRPathEngine -internal val fhirPathEngine: FHIRPathEngine = +private val fhirPathEngine: FHIRPathEngine = with(FhirContext.forCached(FhirVersionEnum.R4)) { FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)).apply { hostServices = FHIRPathEngineHostServices @@ -38,6 +40,20 @@ internal val fhirPathEngine: FHIRPathEngine = internal fun evaluateToDisplay(expressions: List<String>, data: Resource) = expressions.joinToString(" ") { fhirPathEngine.evaluateToString(data, it) } +/** Evaluates the expression over resource [Resource] and returns string value */ +internal fun evaluateToString( + expression: ExpressionNode, + data: Resource?, + contextMap: Map<String, Base?>, +) = + fhirPathEngine.evaluateToString( + /* appInfo = */ contextMap, + /* focusResource = */ null, + /* rootResource = */ null, + /* base = */ data, + /* node = */ expression, + ) + /** * Evaluates the expression and returns the boolean result. The resources [QuestionnaireResponse] * and [QuestionnaireResponseItemComponent] are passed as fhirPath supplements as defined in fhir @@ -60,3 +76,40 @@ internal fun evaluateToBoolean( expressionNode, ) } + +/** + * Evaluates the expression and returns the list of [Base]. The resources [QuestionnaireResponse] + * and [QuestionnaireResponseItemComponent] are passed as fhirPath supplements as defined in fhir + * specs https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements. All other + * constants are passed as contextMap + * + * %resource = [QuestionnaireResponse], %context = [QuestionnaireResponseItemComponent] + */ +internal fun evaluateToBase( + questionnaireResponse: QuestionnaireResponse?, + questionnaireResponseItem: QuestionnaireResponseItemComponent?, + expression: String, + contextMap: Map<String, Base?> = mapOf(), +): List<Base> { + return fhirPathEngine.evaluate( + /* appContext = */ contextMap, + /* focusResource = */ questionnaireResponse, + /* rootResource = */ null, + /* base = */ questionnaireResponseItem, + /* path = */ expression, + ) +} + +/** Evaluates the given expression and returns list of [Base] */ +internal fun evaluateToBase(type: Type, expression: String): List<Base> { + return fhirPathEngine.evaluate( + /* base = */ type, + /* path = */ expression, + ) +} + +/** Evaluates the given list of [Base] elements and returns boolean result */ +internal fun convertToBoolean(items: List<Base>) = fhirPathEngine.convertToBoolean(items) + +/** Parse the given expression into [ExpressionNode] */ +internal fun extractExpressionNode(fhirPath: String) = fhirPathEngine.parse(fhirPath) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt index c4e3a457fb..6ad8088ad3 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt @@ -29,7 +29,7 @@ import com.google.android.fhir.datacapture.extensions.toIdType import com.google.android.fhir.datacapture.extensions.toUriType import com.google.android.fhir.datacapture.extensions.validateLaunchContextExtensions import com.google.android.fhir.datacapture.extensions.zipByLinkId -import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine +import com.google.android.fhir.datacapture.fhirpath.evaluateToBase import java.lang.reflect.Field import java.lang.reflect.Method import java.lang.reflect.ParameterizedType @@ -253,13 +253,11 @@ object ResourceMapper { questionnaireItem.initialExpression ?.let { - fhirPathEngine - .evaluate( - /* appContext= */ launchContexts, - /* focusResource= */ null, - /* rootResource= */ null, - /* base= */ null, - /* path= */ it.expression, + evaluateToBase( + questionnaireResponse = null, + questionnaireResponseItem = null, + expression = it.expression, + contextMap = launchContexts, ) .firstOrNull() } 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 299545eacb..38c0ba42ba 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 @@ -81,6 +81,7 @@ import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.HumanName +import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Practitioner @@ -4679,7 +4680,7 @@ class QuestionnaireViewModelTest { } @Test - fun `should return questionnaire item answer options for answer expression with fhirpath supplement context`() = + fun `should return questionnaire item answer options for answer expression with fhirpath supplement %context`() = runTest { val questionnaire = Questionnaire().apply { @@ -4730,6 +4731,109 @@ class QuestionnaireViewModelTest { } } + @Test + fun `should return questionnaire item answer options for answer expression with fhirpath supplement %questionnaire`() = + runTest { + val questionnaire = + Questionnaire().apply { + this.identifier = listOf(Identifier().apply { value = "A" }) + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + text = "Question 1" + type = Questionnaire.QuestionnaireItemType.CHOICE + repeats = true + initial = + listOf( + Questionnaire.QuestionnaireItemInitialComponent(Coding("test", "1", "One")), + Questionnaire.QuestionnaireItemInitialComponent(Coding("test", "2", "Two")), + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + text = "Q2" + type = Questionnaire.QuestionnaireItemType.STRING + extension = + listOf( + Extension( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression", + Expression().apply { + this.expression = "'Questionnaire = ' + %questionnaire.identifier.value" + this.language = Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() + }, + ), + ) + }, + ) + } + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) + val viewModel = QuestionnaireViewModel(context, state) + + viewModel.runViewModelBlocking { + val viewItem = + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + assertThat(viewItem.enabledAnswerOptions.map { it.valueStringType.value }) + .containsExactly("Questionnaire = A") + } + } + + @Test + fun `should return questionnaire item answer options for answer expression with fhirpath supplement %qItem`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + text = "Question 1" + type = Questionnaire.QuestionnaireItemType.CHOICE + repeats = true + initial = + listOf( + Questionnaire.QuestionnaireItemInitialComponent(Coding("test", "1", "One")), + Questionnaire.QuestionnaireItemInitialComponent(Coding("test", "2", "Two")), + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + text = "Q2" + type = Questionnaire.QuestionnaireItemType.STRING + extension = + listOf( + Extension( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression", + Expression().apply { + this.expression = "'Id of item = ' + %qItem.linkId" + this.language = Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() + }, + ), + ) + }, + ) + } + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) + val viewModel = QuestionnaireViewModel(context, state) + + viewModel.runViewModelBlocking { + val viewItem = + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + assertThat(viewItem.enabledAnswerOptions.map { it.valueStringType.value }) + .containsExactly("Id of item = b") + } + } + // ==================================================================== // // // // Answer Options Toggle Expression // diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/enablement/EnablementEvaluatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/enablement/EnablementEvaluatorTest.kt index 837321cae6..2b2c88deb1 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/enablement/EnablementEvaluatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/enablement/EnablementEvaluatorTest.kt @@ -267,7 +267,7 @@ class EnablementEvaluatorTest { } @Test - fun `evaluate() should evaluate enableWhenExpression with context fhirpath supplement literal`() = + fun `evaluate() should evaluate enableWhenExpression with %context fhirpath supplement literal`() = runBlocking { @Language("JSON") val questionnaireJson = @@ -347,6 +347,144 @@ class EnablementEvaluatorTest { .isTrue() } + @Test + fun `evaluate() should evaluate enableWhenExpression with %questionnaire fhirpath supplement`() = + runBlocking { + @Language("JSON") + val questionnaireJson = + """ + { + "resourceType": "Questionnaire", + "subjectType": "Practitioner", + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%questionnaire.subjectType='Practitioner'" + } + } + ], + "linkId" : "contribution", + "text": "Contribution", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + } + """ + .trimIndent() + + @Language("JSON") + val questionnaireResponseJson = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "contribution", + "answer": [ + { + "valueCoding": { + "code": "yes", + "display": "Yes" + } + } + ] + } + ] + } + """ + .trimIndent() + + val questionnaire = + iParser.parseResource(Questionnaire::class.java, questionnaireJson) as Questionnaire + + val questionnaireResponse = + iParser.parseResource(QuestionnaireResponse::class.java, questionnaireResponseJson) + as QuestionnaireResponse + + assertThat( + EnablementEvaluator(questionnaire, questionnaireResponse) + .evaluate( + questionnaire.item[0], + questionnaireResponse.item[0], + ), + ) + .isTrue() + } + + @Test + fun `evaluate() should evaluate enableWhenExpression with %qItem fhirpath supplement`() = + runBlocking { + @Language("JSON") + val questionnaireJson = + """ + { + "resourceType": "Questionnaire", + "subjectType": "Practitioner", + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%qItem.text = 'Contribution'" + } + } + ], + "linkId" : "contribution", + "text": "Contribution", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + } + """ + .trimIndent() + + @Language("JSON") + val questionnaireResponseJson = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "contribution", + "answer": [ + { + "valueCoding": { + "code": "yes", + "display": "Yes" + } + } + ] + } + ] + } + """ + .trimIndent() + + val questionnaire = + iParser.parseResource(Questionnaire::class.java, questionnaireJson) as Questionnaire + + val questionnaireResponse = + iParser.parseResource(QuestionnaireResponse::class.java, questionnaireResponseJson) + as QuestionnaireResponse + + assertThat( + EnablementEvaluator(questionnaire, questionnaireResponse) + .evaluate( + questionnaire.item[0], + questionnaireResponse.item[0], + ), + ) + .isTrue() + } + @Test fun evaluate_expectAnswerDoesNotExist_answerDoesNotExist_shouldReturnTrue() { assertEnableWhen( diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt index f93f4d9366..1902a2a876 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt @@ -32,6 +32,7 @@ import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.HumanName +import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Location import org.hl7.fhir.r4.model.Patient @@ -429,6 +430,84 @@ class ExpressionEvaluatorTest { } } + @Test + fun `should return not null value with expression for %questionnaire fhirpath supplement`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + identifier = + listOf( + Identifier().apply { value = "q-identifier" }, + ) + + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-item" + text = "a question" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "M" + language = "text/fhirpath" + expression = "%questionnaire.identifier.first().value" + }, + ) + } + }, + ) + } + + val expressionEvaluator = ExpressionEvaluator(questionnaire, QuestionnaireResponse()) + + val result = + expressionEvaluator.evaluateQuestionnaireItemVariableExpression( + questionnaire.item[0].variableExpressions.last(), + questionnaire.item[0], + ) + + assertThat((result as Type).asStringValue()).isEqualTo("q-identifier") + } + + @Test + fun `should return not null value with expression for %qItem fhirpath supplement`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-item" + text = "a question" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "M" + language = "text/fhirpath" + expression = "%qItem.text" + }, + ) + } + }, + ) + } + + val expressionEvaluator = ExpressionEvaluator(questionnaire, QuestionnaireResponse()) + + val result = + expressionEvaluator.evaluateQuestionnaireItemVariableExpression( + questionnaire.item[0].variableExpressions.last(), + questionnaire.item[0], + ) + + assertThat((result as Type).asStringValue()).isEqualTo("a question") + } + @Test fun `should return not null value with expression dependent on answers of items for questionnaire item level`() = runBlocking { @@ -621,6 +700,80 @@ class ExpressionEvaluatorTest { .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -1) }.asStringValue()) } + @Test + fun `evaluateCalculatedExpressions should return list of calculated values with fhirpath supplement %questionnaire`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + identifier = listOf(Identifier().apply { value = "Questionnaire A" }) + + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-questionnaire-reason" + text = "Reason" + type = Questionnaire.QuestionnaireItemType.STRING + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = "%questionnaire.identifier.first().value" + }, + ) + } + }, + ) + } + + val expressionEvaluator = ExpressionEvaluator(questionnaire, QuestionnaireResponse()) + + val result = + expressionEvaluator.evaluateCalculatedExpressions( + questionnaire.item.elementAt(0), + null, + ) + + assertThat(result.first().second.first().asStringValue()).isEqualTo("Questionnaire A") + } + + @Test + fun `evaluateCalculatedExpressions should return list of calculated values with fhirpath supplement %qItem`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + identifier = listOf(Identifier().apply { value = "Questionnaire A" }) + + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-questionnaire-reason" + text = "Reason" + type = Questionnaire.QuestionnaireItemType.STRING + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = "'Question = ' + %qItem.text" + }, + ) + } + }, + ) + } + + val expressionEvaluator = ExpressionEvaluator(questionnaire, QuestionnaireResponse()) + + val result = + expressionEvaluator.evaluateCalculatedExpressions( + questionnaire.item.elementAt(0), + null, + ) + + assertThat(result.first().second.first().asStringValue()).isEqualTo("Question = Reason") + } + @Test fun `detectExpressionCyclicDependency() should throw illegal argument exception when item with calculated expression have cyclic dependency`() { val questionnaire = From 8628708f0e00de16257ce95c217d308011a38fd7 Mon Sep 17 00:00:00 2001 From: aditya-07 <adityakhajuria@google.com> Date: Tue, 26 Dec 2023 16:29:16 +0530 Subject: [PATCH 65/69] Allow sorting in [rev]include results. (#2200) * Allow sorting in [rev]include results * Review comment changes * Fixed failing tests * Lint fails build because of the use of restricted api from package. So, supressing it as couldn't find a workaround for the api * Spotless * Updated failing test * Review comments: Fixed tests * Review comments: Refactored db code to separate out search functions for forward and rev include * Lint fails build because of the use of restricted api UUIDUtil.convertUUIDToByte from androidx.room package. So, supressing it as couldn't find a workaround for the api * Review Comments: Unified the usage of getSortOrder * Review comments: Updated kdoc --- .../android/fhir/db/impl/DatabaseImplTest.kt | 604 ++++++++++++++---- .../com/google/android/fhir/FhirEngine.kt | 40 +- .../com/google/android/fhir/db/Database.kt | 14 +- .../android/fhir/db/impl/DatabaseImpl.kt | 38 +- .../android/fhir/db/impl/dao/ResourceDao.kt | 52 +- .../google/android/fhir/search/MoreSearch.kt | 283 ++++---- .../android/fhir/search/NestedSearch.kt | 18 +- .../search/NumberSearchParameterizedTest.kt | 2 +- .../google/android/fhir/search/SearchTest.kt | 602 +++++++++++++++-- 9 files changed, 1287 insertions(+), 366 deletions(-) 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 3eb337b209..25efca3a2d 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 @@ -25,6 +25,7 @@ import com.google.android.fhir.DateProvider import com.google.android.fhir.FhirServices import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.SearchParamName import com.google.android.fhir.SearchResult import com.google.android.fhir.db.Database import com.google.android.fhir.db.ResourceNotFoundException @@ -47,6 +48,7 @@ import com.google.android.fhir.testing.assertResourceEquals import com.google.android.fhir.testing.readFromFile import com.google.android.fhir.testing.readJsonArrayFromFile import com.google.android.fhir.versionId +import com.google.common.truth.Correspondence import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import java.time.Instant @@ -769,7 +771,7 @@ class DatabaseImplTest { .getQuery(), ) - val ids = results.map { it.id } + val ids = results.map { it.resource.id } assertThat(ids) .containsExactly("RiskAssessment/$largerId", "RiskAssessment/$smallerId") .inOrder() @@ -799,7 +801,7 @@ class DatabaseImplTest { Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "eve" }) }.getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/${patient.id}") + assertThat(result.single().resource.id).isEqualTo("Patient/${patient.id}") } @Test @@ -841,7 +843,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/${patient.id}") + assertThat(result.single().resource.id).isEqualTo("Patient/${patient.id}") } @Test @@ -894,7 +896,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/${patient.id}") + assertThat(result.single().resource.id).isEqualTo("Patient/${patient.id}") } @Test @@ -949,7 +951,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1006,7 +1008,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1064,7 +1066,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1122,7 +1124,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1180,7 +1182,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1237,7 +1239,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1295,7 +1297,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1353,7 +1355,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1411,7 +1413,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1466,7 +1468,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1518,7 +1520,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1569,7 +1571,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1619,7 +1621,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1669,7 +1671,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1719,7 +1721,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1769,7 +1771,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1819,7 +1821,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1869,7 +1871,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1919,7 +1921,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1976,7 +1978,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2040,7 +2042,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2104,7 +2106,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2168,7 +2170,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2232,7 +2234,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2296,7 +2298,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2360,7 +2362,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2424,7 +2426,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2441,7 +2443,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.filter { it.id == patient.id }).hasSize(1) + assertThat(result.filter { it.resource.id == patient.id }).hasSize(1) } @Test @@ -2504,7 +2506,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.map { it.logicalId }).containsExactly("100").inOrder() + assertThat(result.map { it.resource.logicalId }).containsExactly("100").inOrder() } @Test @@ -2550,7 +2552,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.map { it.logicalId }).containsExactly("100").inOrder() + assertThat(result.map { it.resource.logicalId }).containsExactly("100").inOrder() } @Test @@ -2576,7 +2578,7 @@ class DatabaseImplTest { .apply { sort(Patient.BIRTHDATE, Order.DESCENDING) } .getQuery(), ) - .map { it.id }, + .map { it.resource.id }, ) .containsExactly("Patient/younger-patient", "Patient/older-patient", "Patient/test_patient_1") } @@ -2604,7 +2606,7 @@ class DatabaseImplTest { .apply { sort(Patient.BIRTHDATE, Order.ASCENDING) } .getQuery(), ) - .map { it.id }, + .map { it.resource.id }, ) .containsExactly("Patient/test_patient_1", "Patient/older-patient", "Patient/younger-patient") } @@ -2693,7 +2695,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.map { it.vaccineCode.codingFirstRep.code }) + assertThat(result.map { it.resource.vaccineCode.codingFirstRep.code }) .containsExactly("XM1NL1", "XM5DF6") .inOrder() } @@ -2768,7 +2770,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.map { it.vaccineCode.codingFirstRep.code }) + assertThat(result.map { it.resource.vaccineCode.codingFirstRep.code }) .containsExactly("XM1NL1", "XM5DF6") .inOrder() } @@ -2860,7 +2862,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.map { it.nameFirstRep.nameAsSingleString }) + assertThat(result.map { it.resource.nameFirstRep.nameAsSingleString }) .containsExactly("John Doe", "Jane Doe", "John Roe", "Jane Roe") .inOrder() } @@ -2924,7 +2926,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.map { it.nameFirstRep.nameAsSingleString }).contains("Darcy Smith") + assertThat(result.map { it.resource.nameFirstRep.nameAsSingleString }).contains("Darcy Smith") } @Test @@ -2985,7 +2987,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.map { it.nameFirstRep.nameAsSingleString }).contains("Darcy Smith") + assertThat(result.map { it.resource.nameFirstRep.nameAsSingleString }).contains("Darcy Smith") } @Test @@ -3010,7 +3012,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.map { it.logicalId }) + assertThat(result.map { it.resource.logicalId }) .containsAtLeast("patient-test-002", "patient-test-003", "patient-test-001") .inOrder() } @@ -3100,20 +3102,21 @@ class DatabaseImplTest { .execute<Patient>(database) assertThat(result) - .isEqualTo( - listOf( - SearchResult( - patient01, - included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp01)), - revIncluded = null, - ), - SearchResult( - patient02, - included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp03)), - revIncluded = null, - ), + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult( + patient01, + included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp01)), + revIncluded = null, + ), + SearchResult( + patient02, + included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp03)), + revIncluded = null, ), ) + .inOrder() } @Test @@ -3190,22 +3193,23 @@ class DatabaseImplTest { .execute<Patient>(database) assertThat(result) - .isEqualTo( - listOf( - SearchResult( - patient01, - included = null, - revIncluded = - mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con1)), - ), - SearchResult( - patient02, - included = null, - revIncluded = - mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con3)), - ), + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult( + patient01, + included = null, + revIncluded = + mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con1)), + ), + SearchResult( + patient02, + included = null, + revIncluded = + mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con3)), ), ) + .inOrder() } @Test @@ -3509,46 +3513,347 @@ class DatabaseImplTest { .execute<Patient>(database) assertThat(result) - .isEqualTo( - listOf( - SearchResult( - resources["pa-01"]!!, - mapOf( - "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), - "organization" to listOf(resources["org-01"]!!), - ), - mapOf( - Pair(ResourceType.Condition, "subject") to - listOf(resources["con-01-pa-01"]!!, resources["con-03-pa-01"]!!), - Pair(ResourceType.Encounter, "subject") to - listOf(resources["en-01-pa-01"]!!, resources["en-02-pa-01"]!!), - ), + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult( + resources["pa-01"]!!, + mapOf( + "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), + "organization" to listOf(resources["org-01"]!!), ), - SearchResult( - resources["pa-02"]!!, - mapOf( - "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), - "organization" to listOf(resources["org-02"]!!), - ), - mapOf( - Pair(ResourceType.Condition, "subject") to - listOf(resources["con-01-pa-02"]!!, resources["con-03-pa-02"]!!), - Pair(ResourceType.Encounter, "subject") to - listOf(resources["en-01-pa-02"]!!, resources["en-02-pa-02"]!!), - ), + mapOf( + Pair(ResourceType.Condition, "subject") to + listOf(resources["con-01-pa-01"]!!, resources["con-03-pa-01"]!!), + Pair(ResourceType.Encounter, "subject") to + listOf(resources["en-01-pa-01"]!!, resources["en-02-pa-01"]!!), + ), + ), + SearchResult( + resources["pa-02"]!!, + mapOf( + "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), + "organization" to listOf(resources["org-02"]!!), ), - SearchResult( - resources["pa-03"]!!, + mapOf( + Pair(ResourceType.Condition, "subject") to + listOf(resources["con-01-pa-02"]!!, resources["con-03-pa-02"]!!), + Pair(ResourceType.Encounter, "subject") to + listOf(resources["en-01-pa-02"]!!, resources["en-02-pa-02"]!!), + ), + ), + SearchResult( + resources["pa-03"]!!, + mapOf( + "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), + ), + mapOf( + Pair(ResourceType.Condition, "subject") to + listOf(resources["con-01-pa-03"]!!, resources["con-03-pa-03"]!!), + Pair(ResourceType.Encounter, "subject") to + listOf(resources["en-01-pa-03"]!!, resources["en-02-pa-03"]!!), + ), + ), + ) + .inOrder() + } + + @Test + fun search_patient_and_include_practitioners_sorted_by_family_descending(): Unit = runBlocking { + val patient01 = + Patient().apply { + id = "pa-01" + addName( + HumanName().apply { + addGiven("James") + family = "Gorden" + }, + ) + addGeneralPractitioner(Reference("Practitioner/gp-01")) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + addGeneralPractitioner(Reference("Practitioner/gp-04")) + managingOrganization = Reference("Organization/org-01") + } + + val patient02 = + Patient().apply { + id = "pa-02" + addName( + HumanName().apply { + addGiven("James") + family = "Bond" + }, + ) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + addGeneralPractitioner(Reference("Practitioner/gp-03")) + addGeneralPractitioner(Reference("Practitioner/gp-04")) + managingOrganization = Reference("Organization/org-03") + } + val patients = listOf(patient01, patient02) + + val gp01 = + Practitioner().apply { + id = "gp-01" + addName( + HumanName().apply { + family = "Practitioner-01" + addGiven("General-01") + }, + ) + active = true + } + val gp02 = + Practitioner().apply { + id = "gp-02" + addName( + HumanName().apply { + family = "Practitioner-02" + addGiven("General-02") + }, + ) + active = true + } + val gp03 = + Practitioner().apply { + id = "gp-03" + addName( + HumanName().apply { + family = "Practitioner-03" + addGiven("General-03") + }, + ) + active = true + } + + val gp04 = + Practitioner().apply { + id = "gp-04" + addName( + HumanName().apply { + family = "Practitioner-04" + addGiven("General-04") + }, + ) + active = false + } + + val practitioners = listOf(gp01, gp02, gp03, gp04) + + database.insertRemote(*(patients + practitioners).toTypedArray()) + + val result = + Search(ResourceType.Patient) + .apply { + filter( + Patient.GIVEN, + { + value = "James" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + + include<Practitioner>(Patient.GENERAL_PRACTITIONER) { + filter(Practitioner.ACTIVE, { value = of(true) }) + sort(Practitioner.FAMILY, Order.DESCENDING) + } + } + .execute<Patient>(database) + + assertThat(result) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult( + patient01, + included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp02, gp01)), + revIncluded = null, + ), + SearchResult( + patient02, + included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp03, gp02)), + revIncluded = null, + ), + ) + .inOrder() + } + + @Test + fun search_patient_and_revInclude_encounters_sorted_by_date_descending(): Unit = runBlocking { + val patient01 = + Patient().apply { + id = "pa-01" + addName( + HumanName().apply { + addGiven("James") + family = "Gorden" + }, + ) + addGeneralPractitioner(Reference("Practitioner/gp-01")) + } + + val patient02 = + Patient().apply { + id = "pa-02" + addName( + HumanName().apply { + addGiven("James") + family = "Bond" + }, + ) + addGeneralPractitioner(Reference("Practitioner/gp-02")) + } + val patients = listOf(patient01, patient02) + + // encounters for patient 1 + val enc1_1 = + Encounter().apply { + id = "enc1-01" + subject = Reference("Patient/pa-01") + status = Encounter.EncounterStatus.ARRIVED + period = + Period().apply { + start = DateType(2010, 1, 1).value + end = DateType(2010, 1, 2).value + } + } + val enc1_2 = + Encounter().apply { + id = "enc1-02" + subject = Reference("Patient/pa-01") + status = Encounter.EncounterStatus.CANCELLED + period = + Period().apply { + start = DateType(2010, 2, 1).value + end = DateType(2010, 2, 2).value + } + } + + val enc1_3 = + Encounter().apply { + id = "enc1-03" + subject = Reference("Patient/pa-01") + status = Encounter.EncounterStatus.ARRIVED + period = + Period().apply { + start = DateType(2010, 3, 1).value + end = DateType(2010, 3, 2).value + } + } + + val enc1_4 = + Encounter().apply { + id = "enc1-04" + subject = Reference("Patient/pa-01") + status = Encounter.EncounterStatus.ARRIVED + period = + Period().apply { + start = DateType(2010, 4, 1).value + end = DateType(2010, 4, 2).value + } + } + + // encounters for patient 2 + val enc2_1 = + Encounter().apply { + id = "enc2-01" + subject = Reference("Patient/pa-02") + status = Encounter.EncounterStatus.ARRIVED + period = + Period().apply { + start = DateType(2020, 1, 1).value + end = DateType(2020, 1, 2).value + } + } + val enc2_2 = + Encounter().apply { + id = "enc2-02" + subject = Reference("Patient/pa-02") + status = Encounter.EncounterStatus.CANCELLED + period = + Period().apply { + start = DateType(2020, 2, 1).value + end = DateType(2020, 2, 2).value + } + } + + val enc2_3 = + Encounter().apply { + id = "enc2-03" + subject = Reference("Patient/pa-02") + status = Encounter.EncounterStatus.ARRIVED + period = + Period().apply { + start = DateType(2020, 3, 1).value + end = DateType(2020, 3, 2).value + } + } + + val enc2_4 = + Encounter().apply { + id = "enc2-04" + subject = Reference("Patient/pa-02") + status = Encounter.EncounterStatus.ARRIVED + period = + Period().apply { + start = DateType(2020, 4, 1).value + end = DateType(2020, 4, 2).value + } + } + + val encounters = listOf(enc1_1, enc1_2, enc1_3, enc1_4, enc2_1, enc2_2, enc2_3, enc2_4) + database.insertRemote(*(patients + encounters).toTypedArray()) + + val result = + Search(ResourceType.Patient) + .apply { + filter( + Patient.GIVEN, + { + value = "James" + modifier = StringFilterModifier.MATCHES_EXACTLY + }, + ) + + revInclude<Encounter>(Encounter.SUBJECT) { + filter( + Encounter.STATUS, + { + value = + of( + Coding( + "http://hl7.org/fhir/encounter-status", + Encounter.EncounterStatus.ARRIVED.toCode(), + "", + ), + ) + }, + ) + sort(Encounter.DATE, Order.DESCENDING) + } + } + .execute<Patient>(database) + + assertThat(result) + .comparingElementsUsing(SearchResultCorrespondence) + .displayingDiffsPairedBy { it.resource.logicalId } + .containsExactly( + SearchResult( + patient01, + included = null, + revIncluded = mapOf( - "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), + (ResourceType.Encounter to Encounter.SUBJECT.paramName) to + listOf(enc1_4, enc1_3, enc1_1), ), + ), + SearchResult( + patient02, + included = null, + revIncluded = mapOf( - Pair(ResourceType.Condition, "subject") to - listOf(resources["con-01-pa-03"]!!, resources["con-03-pa-03"]!!), - Pair(ResourceType.Encounter, "subject") to - listOf(resources["en-01-pa-03"]!!, resources["en-02-pa-03"]!!), + (ResourceType.Encounter to Encounter.SUBJECT.paramName) to + listOf(enc2_4, enc2_3, enc2_1), ), - ), ), ) } @@ -3762,16 +4067,18 @@ class DatabaseImplTest { // verify that Observation is searchable i.e. ReferenceIndex is updated with new patient ID // reference val searchedObservations = - database.search<Observation>( - Search(ResourceType.Observation) - .apply { - filter( - Observation.SUBJECT, - { value = "Patient/$remotelyCreatedPatientResourceId" }, - ) - } - .getQuery(), - ) + database + .search<Observation>( + Search(ResourceType.Observation) + .apply { + filter( + Observation.SUBJECT, + { value = "Patient/$remotelyCreatedPatientResourceId" }, + ) + } + .getQuery(), + ) + .map { it.resource } assertThat(searchedObservations.size).isEqualTo(1) assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) } @@ -3797,5 +4104,78 @@ class DatabaseImplTest { } @JvmStatic @Parameters(name = "encrypted={0}") fun data(): Array<Boolean> = arrayOf(true, false) + + /** + * [Correspondence] to provide a custom [equalityCheck] for the [SearchResult]s. Also provides a + * custom diff formatting for failing cases. + */ + val SearchResultCorrespondence: Correspondence<SearchResult<Resource>, SearchResult<Resource>> = + Correspondence.from<SearchResult<Resource>, SearchResult<Resource>>( + ::equalityCheck, + "is shallow equals (by logical id comparison) to the ", + ) + .formattingDiffsUsing(::formatDiff) + + private fun <R : Resource> equalityCheck( + actual: SearchResult<R>, + expected: SearchResult<R>, + ): Boolean { + return equalsShallow(actual.resource, expected.resource) && + equalsShallow(actual.included, expected.included) && + equalsShallow(actual.revIncluded, expected.revIncluded) + } + + private fun equalsShallow(first: Resource, second: Resource) = + first.resourceType == second.resourceType && first.logicalId == second.logicalId + + private fun equalsShallow(first: List<Resource>, second: List<Resource>) = + first.size == second.size && + first.asSequence().zip(second.asSequence()).all { (x, y) -> equalsShallow(x, y) } + + private fun equalsShallow( + first: Map<SearchParamName, List<Resource>>?, + second: Map<SearchParamName, List<Resource>>?, + ) = + if (first != null && second != null && first.size == second.size) { + first.entries.asSequence().zip(second.entries.asSequence()).all { (x, y) -> + x.key == y.key && equalsShallow(x.value, y.value) + } + } else { + first?.size == second?.size + } + + @JvmName("equalsShallowRevInclude") + private fun equalsShallow( + first: Map<Pair<ResourceType, SearchParamName>, List<Resource>>?, + second: Map<Pair<ResourceType, SearchParamName>, List<Resource>>?, + ) = + if (first != null && second != null && first.size == second.size) { + first.entries.asSequence().zip(second.entries.asSequence()).all { (x, y) -> + x.key == y.key && equalsShallow(x.value, y.value) + } + } else { + first?.size == second?.size + } + + /** + * Ideally, this functions should highlight the diff between the [actual] and [expected]. But, + * we are just highlighting the ids of resources contained in the [SearchResult]. + */ + private fun <R : Resource> formatDiff( + actual: SearchResult<R>, + expected: SearchResult<R>, + ): String { + return "Expected : ${expected.asString()} \n Actual ${actual.asString()}" + } + + private fun <R : Resource> SearchResult<R>.asString(): String { + return "SearchResult[ resource: " + + resource.logicalId + + ", Included : " + + included?.map { it.key + ": " + it.value.joinToString { it.logicalId } } + + ", RevIncluded : " + + revIncluded?.map { it.key.toString() + ": " + it.value.joinToString { it.logicalId } } + + "]" + } } } diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt index cc0a971721..a006ab2c29 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -153,42 +153,4 @@ data class SearchResult<R : Resource>( val included: Map<SearchParamName, List<Resource>>?, /** Matching referenced resources as per the [Search.revInclude] criteria in the query. */ val revIncluded: Map<Pair<ResourceType, SearchParamName>, List<Resource>>?, -) { - override fun equals(other: Any?) = - other is SearchResult<*> && - equalsShallow(resource, other.resource) && - equalsShallow(included, other.included) && - equalsShallow(revIncluded, other.revIncluded) - - private fun equalsShallow(first: Resource, second: Resource) = - first.resourceType == second.resourceType && first.logicalId == second.logicalId - - private fun equalsShallow(first: List<Resource>, second: List<Resource>) = - first.size == second.size && - first.asSequence().zip(second.asSequence()).all { (x, y) -> equalsShallow(x, y) } - - private fun equalsShallow( - first: Map<SearchParamName, List<Resource>>?, - second: Map<SearchParamName, List<Resource>>?, - ) = - if (first != null && second != null && first.size == second.size) { - first.entries.asSequence().zip(second.entries.asSequence()).all { (x, y) -> - x.key == y.key && equalsShallow(x.value, y.value) - } - } else { - first?.size == second?.size - } - - @JvmName("equalsShallowRevInclude") - private fun equalsShallow( - first: Map<Pair<ResourceType, SearchParamName>, List<Resource>>?, - second: Map<Pair<ResourceType, SearchParamName>, List<Resource>>?, - ) = - if (first != null && second != null && first.size == second.size) { - first.entries.asSequence().zip(second.entries.asSequence()).all { (x, y) -> - x.key == y.key && equalsShallow(x.value, y.value) - } - } else { - first?.size == second?.size - } -} +) diff --git a/engine/src/main/java/com/google/android/fhir/db/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt index 03444c601b..ddbd3a9401 100644 --- a/engine/src/main/java/com/google/android/fhir/db/Database.kt +++ b/engine/src/main/java/com/google/android/fhir/db/Database.kt @@ -18,7 +18,8 @@ package com.google.android.fhir.db import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken -import com.google.android.fhir.db.impl.dao.IndexedIdAndResource +import com.google.android.fhir.db.impl.dao.ForwardIncludeSearchResult +import com.google.android.fhir.db.impl.dao.ReverseIncludeSearchResult import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.search.SearchQuery @@ -94,9 +95,11 @@ internal interface Database { */ suspend fun delete(type: ResourceType, id: String) - suspend fun <R : Resource> search(query: SearchQuery): List<R> + suspend fun <R : Resource> search(query: SearchQuery): List<ResourceWithUUID<R>> - suspend fun searchReferencedResources(query: SearchQuery): List<IndexedIdAndResource> + suspend fun searchForwardReferencedResources(query: SearchQuery): List<ForwardIncludeSearchResult> + + suspend fun searchReverseReferencedResources(query: SearchQuery): List<ReverseIncludeSearchResult> suspend fun count(query: SearchQuery): Long @@ -183,3 +186,8 @@ internal interface Database { */ suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean = false) } + +data class ResourceWithUUID<R>( + val uuid: UUID, + val resource: R, +) 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 47139cf052..59fe65a8e1 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 @@ -27,8 +27,10 @@ import com.google.android.fhir.DatabaseErrorStrategy import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.ResourceNotFoundException +import com.google.android.fhir.db.ResourceWithUUID import com.google.android.fhir.db.impl.DatabaseImpl.Companion.UNENCRYPTED_DATABASE_NAME -import com.google.android.fhir.db.impl.dao.IndexedIdAndResource +import com.google.android.fhir.db.impl.dao.ForwardIncludeSearchResult +import com.google.android.fhir.db.impl.dao.ReverseIncludeSearchResult import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.index.ResourceIndexer import com.google.android.fhir.logicalId @@ -199,23 +201,43 @@ internal class DatabaseImpl( } } - override suspend fun <R : Resource> search(query: SearchQuery): List<R> { + override suspend fun <R : Resource> search( + query: SearchQuery, + ): List<ResourceWithUUID<R>> { return db.withTransaction { resourceDao .getResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) - .map { iParser.parseResource(it) as R } - .distinctBy { it.id } + .map { ResourceWithUUID(it.uuid, iParser.parseResource(it.serializedResource) as R) } + .distinctBy { it.uuid } } } - override suspend fun searchReferencedResources(query: SearchQuery): List<IndexedIdAndResource> { + override suspend fun searchForwardReferencedResources( + query: SearchQuery, + ): List<ForwardIncludeSearchResult> { return db.withTransaction { resourceDao - .getReferencedResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) + .getForwardReferencedResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) .map { - IndexedIdAndResource( + ForwardIncludeSearchResult( it.matchingIndex, - it.idOfBaseResourceOnWhichThisMatchedInc ?: it.idOfBaseResourceOnWhichThisMatchedRev!!, + it.baseResourceUUID, + iParser.parseResource(it.serializedResource) as Resource, + ) + } + } + } + + override suspend fun searchReverseReferencedResources( + query: SearchQuery, + ): List<ReverseIncludeSearchResult> { + return db.withTransaction { + resourceDao + .getReverseReferencedResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) + .map { + ReverseIncludeSearchResult( + it.matchingIndex, + it.baseResourceTypeAndId, iParser.parseResource(it.serializedResource) 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 c31e49439c..bae8f3f6b9 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 @@ -226,12 +226,18 @@ internal abstract class ResourceDao { resourceUuid: UUID, ): ResourceEntity? - @RawQuery abstract suspend fun getResources(query: SupportSQLiteQuery): List<String> + @RawQuery + abstract suspend fun getResources(query: SupportSQLiteQuery): List<SerializedResourceWithUuid> @RawQuery - abstract suspend fun getReferencedResources( + abstract suspend fun getForwardReferencedResources( query: SupportSQLiteQuery, - ): List<IndexedIdAndSerializedResource> + ): List<ForwardIncludeSearchResponse> + + @RawQuery + abstract suspend fun getReverseReferencedResources( + query: SupportSQLiteQuery, + ): List<ReverseIncludeSearchResponse> @RawQuery abstract suspend fun countResources(query: SupportSQLiteQuery): Long @@ -411,23 +417,39 @@ internal abstract class ResourceDao { } } -/** - * Data class representing the value returned by [getReferencedResources]. The optional fields may - * or may-not contain values based on the search query. - */ -internal data class IndexedIdAndSerializedResource( +internal class ForwardIncludeSearchResponse( + @ColumnInfo(name = "index_name") val matchingIndex: String, + @ColumnInfo(name = "resourceUuid") val baseResourceUUID: UUID, + val serializedResource: String, +) + +internal class ReverseIncludeSearchResponse( @ColumnInfo(name = "index_name") val matchingIndex: String, - @ColumnInfo(name = "index_value") val idOfBaseResourceOnWhichThisMatchedRev: String?, - @ColumnInfo(name = "resourceId") val idOfBaseResourceOnWhichThisMatchedInc: String?, + @ColumnInfo(name = "index_value") val baseResourceTypeAndId: String, val serializedResource: String, ) /** - * Data class representing an included or revIncluded [Resource], index on which the match was done - * and the id of the base [Resource] for which this [Resource] has been included. + * Data class representing a forward included [Resource], index on which the match was done and the + * uuid of the base [Resource] for which this [Resource] has been included. */ -internal data class IndexedIdAndResource( - val matchingIndex: String, - val idOfBaseResourceOnWhichThisMatched: String, +internal data class ForwardIncludeSearchResult( + val searchIndex: String, + val baseResourceUUID: UUID, val resource: Resource, ) + +/** + * Data class representing a reverse included [Resource], index on which the match was done and the + * type and logical id of the base [Resource] for which this [Resource] has been included. + */ +internal data class ReverseIncludeSearchResult( + val searchIndex: String, + val baseResourceTypeWithId: String, + val resource: Resource, +) + +internal data class SerializedResourceWithUuid( + @ColumnInfo(name = "resourceUuid") val uuid: UUID, + val serializedResource: String, +) diff --git a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt index a75e2dc126..0075d6facf 100644 --- a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt @@ -16,6 +16,9 @@ package com.google.android.fhir.search +import android.annotation.SuppressLint +import androidx.annotation.VisibleForTesting +import androidx.room.util.convertUUIDToByte import ca.uhn.fhir.rest.gclient.DateClientParam import ca.uhn.fhir.rest.gclient.NumberClientParam import ca.uhn.fhir.rest.gclient.StringClientParam @@ -31,11 +34,13 @@ import com.google.android.fhir.logicalId import com.google.android.fhir.ucumUrl import java.math.BigDecimal import java.util.Date +import java.util.UUID import kotlin.math.absoluteValue import kotlin.math.roundToLong import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Resource +import timber.log.Timber /** * The multiplier used to determine the range for the `ap` search prefix. See @@ -49,35 +54,36 @@ internal suspend fun <R : Resource> Search.execute(database: Database): List<Sea if (forwardIncludes.isEmpty() || baseResources.isEmpty()) { null } else { - database.searchReferencedResources( - getIncludeQuery(includeIds = baseResources.map { it.logicalId }), + database.searchForwardReferencedResources( + getIncludeQuery(includeIds = baseResources.map { it.uuid }), ) } val revIncludedResources = if (revIncludes.isEmpty() || baseResources.isEmpty()) { null } else { - database.searchReferencedResources( - getRevIncludeQuery(includeIds = baseResources.map { "${it.resourceType}/${it.logicalId}" }), + database.searchReverseReferencedResources( + getRevIncludeQuery( + includeIds = baseResources.map { "${it.resource.resourceType}/${it.resource.logicalId}" }, + ), ) } - return baseResources.map { baseResource -> + return baseResources.map { (uuid, baseResource) -> SearchResult( baseResource, included = includedResources ?.asSequence() - ?.filter { it.idOfBaseResourceOnWhichThisMatched == baseResource.logicalId } - ?.groupBy({ it.matchingIndex }, { it.resource }), + ?.filter { it.baseResourceUUID == uuid } + ?.groupBy({ it.searchIndex }, { it.resource }), revIncluded = revIncludedResources ?.asSequence() ?.filter { - it.idOfBaseResourceOnWhichThisMatched == - "${baseResource.fhirType()}/${baseResource.logicalId}" + it.baseResourceTypeWithId == "${baseResource.fhirType()}/${baseResource.logicalId}" } - ?.groupBy({ it.resource.resourceType to it.matchingIndex }, { it.resource }), + ?.groupBy({ it.resource.resourceType to it.searchIndex }, { it.resource }), ) } } @@ -90,136 +96,138 @@ fun Search.getQuery(isCount: Boolean = false): SearchQuery { return getQuery(isCount, null) } -private fun Search.getRevIncludeQuery(includeIds: List<String>): SearchQuery { - var matchQuery = "" +@VisibleForTesting +internal fun Search.getRevIncludeQuery(includeIds: List<String>): SearchQuery { val args = mutableListOf<Any>() - args.addAll(includeIds) + val uuidsString = CharArray(includeIds.size) { '?' }.joinToString() - // creating the match and filter query - revIncludes.forEachIndexed { index, (param, search) -> + fun generateFilterQuery(nestedSearch: NestedSearch): String { + val (param, search) = nestedSearch val resourceToInclude = search.type args.add(resourceToInclude.name) args.add(param.paramName) - matchQuery += " ( a.resourceType = ? and a.index_name IN (?) " - - val allFilters = search.getFilterQueries() + args.addAll(includeIds) + args.add(resourceToInclude.name) - if (allFilters.isNotEmpty()) { - val iterator = allFilters.listIterator() - matchQuery += "AND b.resourceUuid IN (\n" - do { - iterator.next().let { - matchQuery += it.query - args.addAll(it.args) - } + var filterQuery = "" + val filters = search.getFilterQueries() + val iterator = filters.listIterator() + while (iterator.hasNext()) { + iterator.next().let { + filterQuery += it.query + args.addAll(it.args) + } - if (iterator.hasNext()) { - matchQuery += - if (search.operation == Operation.OR) { - "\n UNION \n" - } else { - "\n INTERSECT \n" - } - } - } while (iterator.hasNext()) - matchQuery += "\n)" + if (iterator.hasNext()) { + filterQuery += + if (search.operation == Operation.OR) { + "\n UNION \n" + } else { + "\n INTERSECT \n" + } + } } - - matchQuery += " \n)" - - if (index != revIncludes.lastIndex) matchQuery += " OR " + return filterQuery } - return SearchQuery( - query = + return revIncludes + .map { + val (join, order) = it.search.getSortOrder(otherTable = "re") + args.addAll(join.args) + val filterQuery = generateFilterQuery(it) """ - SELECT a.index_name, a.index_value, b.serializedResource - FROM ReferenceIndexEntity a - JOIN ResourceEntity b - ON a.resourceUuid = b.resourceUuid - AND a.index_value IN( ${ CharArray(includeIds.size) { '?' }.joinToString()} ) - ${if (matchQuery.isEmpty()) "" else "AND ($matchQuery) " } - """ - .trimIndent(), - args = args, - ) + SELECT rie.index_name, rie.index_value, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceUuid = rie.resourceUuid + ${join.query} + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.index_value IN ($uuidsString) AND re.resourceType = ? + ${if (filterQuery.isNotEmpty()) "AND re.resourceUuid IN ($filterQuery)" else ""} + $order + """ + .trimIndent() + } + .joinToString("\nUNION ALL\n") { + StringBuilder("SELECT * FROM (\n").append(it.trim()).append("\n)") + } + .split("\n") + .filter { it.isNotBlank() } + .joinToString("\n") { it.trim() } + .let { SearchQuery(it, args) } } -private fun Search.getIncludeQuery(includeIds: List<String>): SearchQuery { - var matchQuery = "" - val args = mutableListOf<Any>(type.name) - args.addAll(includeIds) +@SuppressLint("RestrictedApi") +@VisibleForTesting +internal fun Search.getIncludeQuery(includeIds: List<UUID>): SearchQuery { + val args = mutableListOf<Any>() + val baseResourceType = type + val uuidsString = CharArray(includeIds.size) { '?' }.joinToString() - // creating the match and filter query - forwardIncludes.forEachIndexed { index, (param, search) -> + fun generateFilterQuery(nestedSearch: NestedSearch): String { + val (param, search) = nestedSearch val resourceToInclude = search.type - args.add(resourceToInclude.name) + args.add(baseResourceType.name) args.add(param.paramName) - matchQuery += " ( c.resourceType = ? and b.index_name IN (?) " - - val allFilters = search.getFilterQueries() + args.addAll(includeIds.map { convertUUIDToByte(it) }) + args.add(resourceToInclude.name) - if (allFilters.isNotEmpty()) { - val iterator = allFilters.listIterator() - matchQuery += "AND c.resourceUuid IN (\n" - do { - iterator.next().let { - matchQuery += it.query - args.addAll(it.args) - } + var filterQuery = "" + val filters = search.getFilterQueries() + val iterator = filters.listIterator() + while (iterator.hasNext()) { + iterator.next().let { + filterQuery += it.query + args.addAll(it.args) + } - if (iterator.hasNext()) { - matchQuery += - if (search.operation == Operation.OR) { - "\n UNION \n" - } else { - "\n INTERSECT \n" - } - } - } while (iterator.hasNext()) - matchQuery += "\n)" + if (iterator.hasNext()) { + filterQuery += + if (search.operation == Operation.OR) { + "\nUNION\n" + } else { + "\nINTERSECT\n" + } + } } - - matchQuery += " \n)" - - if (index != forwardIncludes.lastIndex) matchQuery += " OR " + return filterQuery } - return SearchQuery( - query = - // spotless:off - """ - SELECT b.index_name, a.resourceId, c.serializedResource from ResourceEntity a - JOIN ReferenceIndexEntity b - On a.resourceUuid = b.resourceUuid - AND a.resourceType = ? - AND a.resourceId IN ( ${ CharArray(includeIds.size) { '?' }.joinToString()} ) - JOIN ResourceEntity c - ON c.resourceType||"/"||c.resourceId = b.index_value - ${if (matchQuery.isEmpty()) "" else "AND ($matchQuery) " } - """.trimIndent(), - // spotless:on - args = args, - ) + return forwardIncludes + .map { + val (join, order) = it.search.getSortOrder(otherTable = "re") + args.addAll(join.args) + val filterQuery = generateFilterQuery(it) + """ + SELECT rie.index_name, rie.resourceUuid, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceType||"/"||re.resourceId = rie.index_value + ${join.query} + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.resourceUuid IN ($uuidsString) AND re.resourceType = ? + ${if (filterQuery.isNotEmpty()) "AND re.resourceUuid IN ($filterQuery)" else ""} + $order + """ + .trimIndent() + } + .joinToString("\nUNION ALL\n") { + StringBuilder("SELECT * FROM (\n").append(it.trim()).append("\n)") + } + .split("\n") + .filter { it.isNotBlank() } + .joinToString("\n") { it.trim() } + .let { SearchQuery(it, args) } } -private fun Search.getFilterQueries() = - (stringFilterCriteria + - quantityFilterCriteria + - numberFilterCriteria + - referenceFilterCriteria + - dateTimeFilterCriteria + - tokenFilterCriteria + - uriFilterCriteria) - .map { it.query(type) } - -internal fun Search.getQuery( - isCount: Boolean = false, - nestedContext: NestedContext? = null, -): SearchQuery { +private fun Search.getSortOrder( + otherTable: String, + isReferencedSearch: Boolean = false, +): Pair<SearchQuery, String> { var sortJoinStatement = "" var sortOrderStatement = "" - val sortArgs = mutableListOf<Any>() + val args = mutableListOf<Any>() + if (isReferencedSearch && count != null) { + Timber.e("count not supported for [rev]include search.") + } sort?.let { sort -> val sortTableNames = when (sort) { @@ -232,20 +240,20 @@ internal fun Search.getQuery( listOf(SortTableInfo.DATE_SORT_TABLE_INFO, SortTableInfo.DATE_TIME_SORT_TABLE_INFO) else -> throw NotImplementedError("Unhandled sort parameter of type ${sort::class}: $sort") } - sortJoinStatement = "" - - sortTableNames.forEachIndexed { index, sortTableName -> - val tableAlias = 'b' + index - sortJoinStatement += - // spotless:off + sortJoinStatement = + sortTableNames + .mapIndexed { index, sortTableName -> + val tableAlias = 'b' + index + // spotless:off """ LEFT JOIN ${sortTableName.tableName} $tableAlias - ON a.resourceType = $tableAlias.resourceType AND a.resourceUuid = $tableAlias.resourceUuid AND $tableAlias.index_name = ? + ON $otherTable.resourceType = $tableAlias.resourceType AND $otherTable.resourceUuid = $tableAlias.resourceUuid AND $tableAlias.index_name = ? """ - // spotless:on - sortArgs += sort.paramName - } + // spotless:on + } + .joinToString(separator = "\n") + sortTableNames.forEach { _ -> args.add(sort.paramName) } sortTableNames.forEachIndexed { index, sortTableName -> val tableAlias = 'b' + index @@ -253,13 +261,34 @@ internal fun Search.getQuery( if (index == 0) { """ ORDER BY $tableAlias.${sortTableName.columnName} ${order.sqlString} - """ + """ .trimIndent() } else { ", $tableAlias.${SortTableInfo.DATE_TIME_SORT_TABLE_INFO.columnName} ${order.sqlString}" } } } + return Pair(SearchQuery(sortJoinStatement, args), sortOrderStatement) +} + +private fun Search.getFilterQueries() = + (stringFilterCriteria + + quantityFilterCriteria + + numberFilterCriteria + + referenceFilterCriteria + + dateTimeFilterCriteria + + tokenFilterCriteria + + uriFilterCriteria) + .map { it.query(type) } + +internal fun Search.getQuery( + isCount: Boolean = false, + nestedContext: NestedContext? = null, +): SearchQuery { + val (join, order) = getSortOrder(otherTable = "a") + val sortJoinStatement = join.query + val sortOrderStatement = order + val sortArgs = join.args var filterStatement = "" val filterArgs = mutableListOf<Any>() @@ -331,7 +360,7 @@ internal fun Search.getQuery( else -> // spotless:off """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a $sortJoinStatement WHERE a.resourceType = ? diff --git a/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt b/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt index fe027fc272..1f63ed63bd 100644 --- a/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt @@ -68,7 +68,9 @@ inline fun <reified R : Resource> Search.has( * } * ``` * - * **NOTE**: [include] doesn't support order OR count. + * **NOTE**: + * * [include] doesn't support count. + * * Multiple includes of the same resource type do not guarantee the order of returned resources. */ inline fun <reified R : Resource> Search.include( referenceParam: ReferenceClientParam, @@ -99,7 +101,9 @@ inline fun <reified R : Resource> Search.include( * } * ``` * - * **NOTE**: [include] doesn't support order OR count. + * **NOTE**: + * * [include] doesn't support count. + * * Multiple includes of the same resource type do not guarantee the order of returned resources. */ fun Search.include( resourceType: ResourceType, @@ -129,7 +133,10 @@ fun Search.include( * } * ``` * - * **NOTE**: [revInclude] doesn't support order OR count. + * **NOTE**: + * * [revInclude] doesn't support count. + * * Multiple revIncludes of the same resource type do not guarantee the order of returned + * resources. */ inline fun <reified R : Resource> Search.revInclude( referenceParam: ReferenceClientParam, @@ -160,7 +167,10 @@ inline fun <reified R : Resource> Search.revInclude( * } * ``` * - * **NOTE**: [revInclude] doesn't support order OR count. + * **NOTE**: + * * [revInclude] doesn't support count. + * * Multiple revIncludes of the same resource type do not guarantee the order of returned + * resources. */ fun Search.revInclude( resourceType: ResourceType, diff --git a/engine/src/test/java/com/google/android/fhir/search/NumberSearchParameterizedTest.kt b/engine/src/test/java/com/google/android/fhir/search/NumberSearchParameterizedTest.kt index 7a6b8f6f26..c00ff7dbc5 100644 --- a/engine/src/test/java/com/google/android/fhir/search/NumberSearchParameterizedTest.kt +++ b/engine/src/test/java/com/google/android/fhir/search/NumberSearchParameterizedTest.kt @@ -51,7 +51,7 @@ class NumberSearchParameterizedTest( private val baseQuery: String = """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( diff --git a/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt b/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt index 34e9d71692..c9da480147 100644 --- a/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt +++ b/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt @@ -17,14 +17,17 @@ package com.google.android.fhir.search import android.os.Build +import androidx.room.util.convertUUIDToByte import ca.uhn.fhir.model.api.TemporalPrecisionEnum import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.DateProvider import com.google.android.fhir.epochDay +import com.google.common.truth.Correspondence import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import java.time.Instant import java.util.Date +import java.util.UUID import kotlin.math.absoluteValue import kotlin.math.roundToLong import kotlinx.coroutines.runBlocking @@ -35,11 +38,14 @@ import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.ContactPoint import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType +import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Immunization import org.hl7.fhir.r4.model.Library import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Organization import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Practitioner import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.RiskAssessment import org.hl7.fhir.r4.model.UriType @@ -60,7 +66,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? """ @@ -92,7 +98,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? LIMIT ? @@ -115,7 +121,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? LIMIT ? OFFSET ? @@ -146,7 +152,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -199,7 +205,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -239,7 +245,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -279,7 +285,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -314,7 +320,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -357,7 +363,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -397,7 +403,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -437,7 +443,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -477,7 +483,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -522,7 +528,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -576,7 +582,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -616,7 +622,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -656,7 +662,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -699,7 +705,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -742,7 +748,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -782,7 +788,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -822,7 +828,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -862,7 +868,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -894,7 +900,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -931,7 +937,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -968,7 +974,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1005,7 +1011,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1045,7 +1051,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1080,7 +1086,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1125,7 +1131,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1169,7 +1175,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1199,7 +1205,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1228,7 +1234,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1264,7 +1270,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1295,7 +1301,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1334,7 +1340,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1375,7 +1381,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1415,7 +1421,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1455,7 +1461,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1494,7 +1500,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1532,7 +1538,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1571,7 +1577,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1609,7 +1615,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1649,7 +1655,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1681,7 +1687,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1708,7 +1714,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a LEFT JOIN StringIndexEntity b ON a.resourceType = b.resourceType AND a.resourceUuid = b.resourceUuid AND b.index_name = ? @@ -1728,7 +1734,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a LEFT JOIN StringIndexEntity b ON a.resourceType = b.resourceType AND a.resourceUuid = b.resourceUuid AND b.index_name = ? @@ -1750,7 +1756,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a LEFT JOIN NumberIndexEntity b ON a.resourceType = b.resourceType AND a.resourceUuid = b.resourceUuid AND b.index_name = ? @@ -1776,7 +1782,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a LEFT JOIN StringIndexEntity b ON a.resourceType = b.resourceType AND a.resourceUuid = b.resourceUuid AND b.index_name = ? @@ -1821,7 +1827,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1897,7 +1903,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -1970,7 +1976,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -2032,7 +2038,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a LEFT JOIN DateIndexEntity b ON a.resourceType = b.resourceType AND a.resourceUuid = b.resourceUuid AND b.index_name = ? @@ -2053,7 +2059,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a LEFT JOIN DateIndexEntity b ON a.resourceType = b.resourceType AND a.resourceUuid = b.resourceUuid AND b.index_name = ? @@ -2089,7 +2095,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -2130,7 +2136,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -2163,7 +2169,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -2194,7 +2200,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -2232,7 +2238,7 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? AND a.resourceUuid IN ( @@ -2255,8 +2261,490 @@ class SearchTest { ) } + @Test + fun `search include all practitioners`() { + val query = + Search(ResourceType.Patient) + .apply { include<Practitioner>(Patient.GENERAL_PRACTITIONER) } + .getIncludeQuery( + listOf( + UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb"), + UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb"), + ), + ) + + assertThat(query.query) + .isEqualTo( + """ + SELECT * FROM ( + SELECT rie.index_name, rie.resourceUuid, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceType||"/"||re.resourceId = rie.index_value + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.resourceUuid IN (?, ?) AND re.resourceType = ? + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .comparingElementsUsing(ArgsComparator) + .containsExactly( + "Patient", + "general-practitioner", + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb")), + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb")), + "Practitioner", + ) + .inOrder() + } + + @Test + fun `search include all active practitioners`() { + val query = + Search(ResourceType.Patient) + .apply { + include<Practitioner>(Patient.GENERAL_PRACTITIONER) { + filter(Practitioner.ACTIVE, { value = of(true) }) + } + } + .getIncludeQuery( + listOf( + UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb"), + UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb"), + ), + ) + + assertThat(query.query) + .isEqualTo( + """ + SELECT * FROM ( + SELECT rie.index_name, rie.resourceUuid, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceType||"/"||re.resourceId = rie.index_value + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.resourceUuid IN (?, ?) AND re.resourceType = ? + AND re.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND index_value = ? + ) + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .comparingElementsUsing(ArgsComparator) + .containsExactly( + "Patient", + "general-practitioner", + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb")), + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb")), + "Practitioner", + "Practitioner", + "active", + "true", + ) + .inOrder() + } + + @Test + fun `search include all active practitioners and sort by given name`() { + val query = + Search(ResourceType.Patient) + .apply { + include<Practitioner>(Patient.GENERAL_PRACTITIONER) { + filter(Practitioner.ACTIVE, { value = of(true) }) + sort(Practitioner.GIVEN, Order.DESCENDING) + } + } + .getIncludeQuery( + listOf( + UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb"), + UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb"), + ), + ) + + assertThat(query.query) + .isEqualTo( + """ + SELECT * FROM ( + SELECT rie.index_name, rie.resourceUuid, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceType||"/"||re.resourceId = rie.index_value + LEFT JOIN StringIndexEntity b + ON re.resourceType = b.resourceType AND re.resourceUuid = b.resourceUuid AND b.index_name = ? + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.resourceUuid IN (?, ?) AND re.resourceType = ? + AND re.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND index_value = ? + ) + ORDER BY b.index_value DESC + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .comparingElementsUsing(ArgsComparator) + .containsExactly( + "given", + "Patient", + "general-practitioner", + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb")), + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb")), + "Practitioner", + "Practitioner", + "active", + "true", + ) + .inOrder() + } + + @Test + fun `search include practitioners and organizations`() { + val query = + Search(ResourceType.Patient) + .apply { + include<Practitioner>(Patient.GENERAL_PRACTITIONER) { + filter(Practitioner.ACTIVE, { value = of(true) }) + sort(Practitioner.GIVEN, Order.DESCENDING) + } + + include<Organization>(Patient.ORGANIZATION) { + filter(Organization.ACTIVE, { value = of(true) }) + sort(Organization.NAME, Order.DESCENDING) + } + } + .getIncludeQuery( + listOf( + UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb"), + UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb"), + ), + ) + + assertThat(query.query) + .isEqualTo( + """ + SELECT * FROM ( + SELECT rie.index_name, rie.resourceUuid, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceType||"/"||re.resourceId = rie.index_value + LEFT JOIN StringIndexEntity b + ON re.resourceType = b.resourceType AND re.resourceUuid = b.resourceUuid AND b.index_name = ? + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.resourceUuid IN (?, ?) AND re.resourceType = ? + AND re.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND index_value = ? + ) + ORDER BY b.index_value DESC + ) + UNION ALL + SELECT * FROM ( + SELECT rie.index_name, rie.resourceUuid, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceType||"/"||re.resourceId = rie.index_value + LEFT JOIN StringIndexEntity b + ON re.resourceType = b.resourceType AND re.resourceUuid = b.resourceUuid AND b.index_name = ? + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.resourceUuid IN (?, ?) AND re.resourceType = ? + AND re.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND index_value = ? + ) + ORDER BY b.index_value DESC + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .comparingElementsUsing(ArgsComparator) + .containsExactly( + "given", + "Patient", + "general-practitioner", + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb")), + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb")), + "Practitioner", + "Practitioner", + "active", + "true", + "name", + "Patient", + "organization", + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-2029-a12c-108d1eb5bedb")), + convertUUIDToByte(UUID.fromString("e2c79e28-ed4d-4029-a12c-108d1eb5bedb")), + "Organization", + "Organization", + "active", + "true", + ) + .inOrder() + } + + @Test + fun `search revInclude all conditions for patients`() { + val query = + Search(ResourceType.Patient) + .apply { revInclude<Condition>(Condition.SUBJECT) } + .getRevIncludeQuery(listOf("Patient/pa01", "Patient/pa02")) + + assertThat(query.query) + .isEqualTo( + """ + SELECT * FROM ( + SELECT rie.index_name, rie.index_value, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceUuid = rie.resourceUuid + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.index_value IN (?, ?) AND re.resourceType = ? + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .containsExactly("Condition", "subject", "Patient/pa01", "Patient/pa02", "Condition") + .inOrder() + } + + @Test + fun `search revInclude diabetic conditions for patients`() { + val query = + Search(ResourceType.Patient) + .apply { + revInclude<Condition>(Condition.SUBJECT) { + filter( + Condition.CODE, + { + value = + of( + CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")), + ) + }, + ) + } + } + .getRevIncludeQuery(listOf("Patient/pa01", "Patient/pa02")) + + assertThat(query.query) + .isEqualTo( + """ + SELECT * FROM ( + SELECT rie.index_name, rie.index_value, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceUuid = rie.resourceUuid + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.index_value IN (?, ?) AND re.resourceType = ? + AND re.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) + ) + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .containsExactly( + "Condition", + "subject", + "Patient/pa01", + "Patient/pa02", + "Condition", + "Condition", + "code", + "44054006", + "http://snomed.info/sct", + ) + .inOrder() + } + + @Test + fun `search revInclude diabetic conditions for patients and sort by recorded date`() { + val query = + Search(ResourceType.Patient) + .apply { + revInclude<Condition>(Condition.SUBJECT) { + filter( + Condition.CODE, + { + value = + of( + CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")), + ) + }, + ) + sort(Condition.RECORDED_DATE, Order.DESCENDING) + } + } + .getRevIncludeQuery(listOf("Patient/pa01", "Patient/pa02")) + + assertThat(query.query) + .isEqualTo( + """ + SELECT * FROM ( + SELECT rie.index_name, rie.index_value, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceUuid = rie.resourceUuid + LEFT JOIN DateIndexEntity b + ON re.resourceType = b.resourceType AND re.resourceUuid = b.resourceUuid AND b.index_name = ? + LEFT JOIN DateTimeIndexEntity c + ON re.resourceType = c.resourceType AND re.resourceUuid = c.resourceUuid AND c.index_name = ? + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.index_value IN (?, ?) AND re.resourceType = ? + AND re.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) + ) + ORDER BY b.index_from DESC, c.index_from DESC + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .containsExactly( + "recorded-date", + "recorded-date", + "Condition", + "subject", + "Patient/pa01", + "Patient/pa02", + "Condition", + "Condition", + "code", + "44054006", + "http://snomed.info/sct", + ) + .inOrder() + } + + @Test + fun `search revInclude encounters and conditions filtered and sorted`() { + val query = + Search(ResourceType.Patient) + .apply { + revInclude<Encounter>(Encounter.SUBJECT) { + filter( + Encounter.STATUS, + { + value = + of( + Coding( + "http://hl7.org/fhir/encounter-status", + Encounter.EncounterStatus.ARRIVED.toCode(), + "", + ), + ) + }, + ) + sort(Encounter.DATE, Order.DESCENDING) + } + + revInclude<Condition>(Condition.SUBJECT) { + filter( + Condition.CODE, + { + value = + of( + CodeableConcept(Coding("http://snomed.info/sct", "44054006", "Diabetes")), + ) + }, + ) + sort(Condition.RECORDED_DATE, Order.DESCENDING) + } + } + .getRevIncludeQuery(listOf("Patient/pa01", "Patient/pa02")) + + assertThat(query.query) + .isEqualTo( + """ + SELECT * FROM ( + SELECT rie.index_name, rie.index_value, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceUuid = rie.resourceUuid + LEFT JOIN DateIndexEntity b + ON re.resourceType = b.resourceType AND re.resourceUuid = b.resourceUuid AND b.index_name = ? + LEFT JOIN DateTimeIndexEntity c + ON re.resourceType = c.resourceType AND re.resourceUuid = c.resourceUuid AND c.index_name = ? + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.index_value IN (?, ?) AND re.resourceType = ? + AND re.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) + ) + ORDER BY b.index_from DESC, c.index_from DESC + ) + UNION ALL + SELECT * FROM ( + SELECT rie.index_name, rie.index_value, re.serializedResource + FROM ResourceEntity re + JOIN ReferenceIndexEntity rie + ON re.resourceUuid = rie.resourceUuid + LEFT JOIN DateIndexEntity b + ON re.resourceType = b.resourceType AND re.resourceUuid = b.resourceUuid AND b.index_name = ? + LEFT JOIN DateTimeIndexEntity c + ON re.resourceType = c.resourceType AND re.resourceUuid = c.resourceUuid AND c.index_name = ? + WHERE rie.resourceType = ? AND rie.index_name = ? AND rie.index_value IN (?, ?) AND re.resourceType = ? + AND re.resourceUuid IN ( + SELECT resourceUuid FROM TokenIndexEntity + WHERE resourceType = ? AND index_name = ? AND (index_value = ? AND IFNULL(index_system,'') = ?) + ) + ORDER BY b.index_from DESC, c.index_from DESC + ) + """ + .trimIndent(), + ) + + assertThat(query.args) + .containsExactly( + "date", + "date", + "Encounter", + "subject", + "Patient/pa01", + "Patient/pa02", + "Encounter", + "Encounter", + "status", + "arrived", + "http://hl7.org/fhir/encounter-status", + "recorded-date", + "recorded-date", + "Condition", + "subject", + "Patient/pa01", + "Patient/pa02", + "Condition", + "Condition", + "code", + "44054006", + "http://snomed.info/sct", + ) + .inOrder() + } + private companion object { const val mockEpochTimeStamp = 1628516301000 const val APPROXIMATION_COEFFICIENT = 0.1 + + /** + * Custom implementation to equality check values of [com.google.common.truth.IterableSubject]. + */ + val ArgsComparator: Correspondence<Any, Any> = + Correspondence.from( + { a, b -> + if (a is ByteArray && b is ByteArray) { + a.contentEquals(b) + } else { + a == b + } + }, + "", + ) } } From 2df5e026b395f0511ad4ea3d8a1209a65e6574a3 Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Tue, 2 Jan 2024 18:34:28 +0530 Subject: [PATCH 66/69] use preference datastore to monitor syncJobStatus. (#2142) * Update syncJobStatus to preference datastore. * add fhirdatastore file. * code cleanup. * unit tests * unit test. * Cancel ongoing coroutine job before launching new oneTimeSync in demo app. * Fix android test, pass context instead of datastore in fhir engine configuration. * fix tests. * update kotlin doc. * combine work state with syncjobstatus * Address review comments. * Address review comments. * Update OneTimeSyncState and PeriodicSyncState. * missing file. * Stores sync job terminal state in the datastore. * Address review comments. * Address review comments. * Address review comments. * Address review comments. * code clean up. * Code clean up. * Fix test. * Fix test * Fix tests. * Address review comments. * Address review comments. * Remove DataStoreUtil. * Address review comments. * fix crash. * refactoring names * spotless apply * private access specifier * Address review comments. * Address review comment. * address review comments. * fix tests. * review comment. * Removing existing unwanted call. * Address review comments. * Add test to assert succedded sync state. * failed state was nit emitted. * Update api doc. * Address review comments. * Review address comments. * Address review comments. * Address review comment. * Address review comments. * Address review comment. * Address review comments. * remove unwanted file. --------- Co-authored-by: Santosh Pingle <spingle@google.com> --- .../google/android/fhir/demo/MainActivity.kt | 2 +- .../fhir/demo/MainActivityViewModel.kt | 39 ++- .../android/fhir/demo/PatientListFragment.kt | 105 ++++++-- .../fhir/benchmark/FhirSyncWorkerBenchmark.kt | 10 +- .../android/fhir/sync/SyncInstrumentedTest.kt | 120 ++++++++- .../com/google/android/fhir/DatastoreUtil.kt | 46 ---- .../google/android/fhir/FhirEngineProvider.kt | 12 +- .../com/google/android/fhir/FhirServices.kt | 5 +- .../android/fhir/impl/FhirEngineImpl.kt | 6 +- .../com/google/android/fhir/sync/Config.kt | 4 +- .../google/android/fhir/sync/FhirDataStore.kt | 107 ++++++++ .../android/fhir/sync/FhirSyncWorker.kt | 31 ++- .../android/fhir/sync/FhirSynchronizer.kt | 12 +- .../java/com/google/android/fhir/sync/Sync.kt | 241 ++++++++++++++++-- .../google/android/fhir/sync/SyncJobStatus.kt | 148 ++++++++++- .../android/fhir/FhirEngineProviderTest.kt | 14 +- .../android/fhir/sync/FhirDataStoreTest.kt | 46 ++++ .../android/fhir/sync/FhirSyncWorkerTest.kt | 8 +- .../android/fhir/sync/FhirSynchronizerTest.kt | 52 ++-- .../com/google/android/fhir/sync/SyncTest.kt | 8 +- 20 files changed, 833 insertions(+), 183 deletions(-) delete mode 100644 engine/src/main/java/com/google/android/fhir/DatastoreUtil.kt create mode 100644 engine/src/main/java/com/google/android/fhir/sync/FhirDataStore.kt create mode 100644 engine/src/test/java/com/google/android/fhir/sync/FhirDataStoreTest.kt diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt index b74a418735..422110d638 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index 0097178c39..3410bf0332 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.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. @@ -24,13 +24,16 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.work.Constraints import com.google.android.fhir.demo.data.DemoFhirSyncWorker +import com.google.android.fhir.sync.CurrentSyncJobStatus import com.google.android.fhir.sync.PeriodicSyncConfiguration +import com.google.android.fhir.sync.PeriodicSyncJobStatus import com.google.android.fhir.sync.RepeatInterval import com.google.android.fhir.sync.Sync -import com.google.android.fhir.sync.SyncJobStatus +import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -44,10 +47,14 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica val lastSyncTimestampLiveData: LiveData<String> get() = _lastSyncTimestampLiveData - private val _pollState = MutableSharedFlow<SyncJobStatus>() - val pollState: Flow<SyncJobStatus> + private val _pollState = MutableSharedFlow<CurrentSyncJobStatus>() + val pollState: Flow<CurrentSyncJobStatus> get() = _pollState + private val _pollPeriodicSyncJobStatus = MutableSharedFlow<PeriodicSyncJobStatus>() + val pollPeriodicSyncJobStatus: Flow<PeriodicSyncJobStatus> + get() = _pollPeriodicSyncJobStatus + init { viewModelScope.launch { Sync.periodicSync<DemoFhirSyncWorker>( @@ -59,26 +66,34 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica ), ) .shareIn(this, SharingStarted.Eagerly, 10) - .collect { _pollState.emit(it) } + .collect { _pollPeriodicSyncJobStatus.emit(it) } } } + private var oneTimeSyncJob: Job? = null + fun triggerOneTimeSync() { - viewModelScope.launch { - Sync.oneTimeSync<DemoFhirSyncWorker>(getApplication()) - .shareIn(this, SharingStarted.Eagerly, 10) - .collect { _pollState.emit(it) } - } + // Cancels any ongoing sync job before starting a new one. Since this function may be called + // more than once, not canceling the ongoing job could result in the creation of multiple jobs + // that emit the same object. + oneTimeSyncJob?.cancel() + oneTimeSyncJob = + viewModelScope.launch { + Sync.oneTimeSync<DemoFhirSyncWorker>(getApplication()) + .shareIn(this, SharingStarted.Eagerly, 0) + .collect { result -> result.let { _pollState.emit(it) } } + } } /** Emits last sync time. */ - fun updateLastSyncTimestamp() { + fun updateLastSyncTimestamp(lastSync: OffsetDateTime? = null) { val formatter = DateTimeFormatter.ofPattern( if (DateFormat.is24HourFormat(getApplication())) formatString24 else formatString12, ) _lastSyncTimestampLiveData.value = - Sync.getLastSyncTimestamp(getApplication())?.toLocalDateTime()?.format(formatter) ?: "" + lastSync?.let { it.toLocalDateTime()?.format(formatter) ?: "" } + ?: Sync.getLastSyncTimestamp(getApplication())?.toLocalDateTime()?.format(formatter) ?: "" } companion object { diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt index 2f4fc18b64..5d6bc8a10b 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,6 +44,8 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.FhirEngine import com.google.android.fhir.demo.PatientListViewModel.PatientListViewModelFactory import com.google.android.fhir.demo.databinding.FragmentPatientListBinding +import com.google.android.fhir.sync.CurrentSyncJobStatus +import com.google.android.fhir.sync.LastSyncJobStatus import com.google.android.fhir.sync.SyncJobStatus import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -105,6 +107,7 @@ class PatientListFragment : Fragment() { searchView = binding.search topBanner = binding.syncStatusContainer.linearLayoutSyncStatus + topBanner.visibility = View.GONE syncStatus = binding.syncStatusContainer.tvSyncingStatus syncPercent = binding.syncStatusContainer.tvSyncingPercent syncProgress = binding.syncStatusContainer.progressSyncing @@ -155,32 +158,68 @@ class PatientListFragment : Fragment() { mainActivityViewModel.pollState.collect { Timber.d("onViewCreated: pollState Got status $it") when (it) { - is SyncJobStatus.Started -> { - Timber.i("Sync: ${it::class.java.simpleName}") + is CurrentSyncJobStatus.Running -> { + Timber.i("Sync: ${it::class.java.simpleName} with data ${it.inProgressSyncJob}") fadeInTopBanner(it) } - is SyncJobStatus.InProgress -> { - Timber.i("Sync: ${it::class.java.simpleName} with data $it") - fadeInTopBanner(it) - } - is SyncJobStatus.Finished -> { + is CurrentSyncJobStatus.Succeeded -> { Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp() + mainActivityViewModel.updateLastSyncTimestamp(it.timestamp) fadeOutTopBanner(it) } - is SyncJobStatus.Failed -> { + is CurrentSyncJobStatus.Failed -> { Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp() + mainActivityViewModel.updateLastSyncTimestamp(it.timestamp) fadeOutTopBanner(it) } - else -> { - Timber.i("Sync: Unknown state.") + is CurrentSyncJobStatus.Enqueued -> { + Timber.i("Sync: Enqueued") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) } + CurrentSyncJobStatus.Cancelled -> TODO() + } + } + } + + lifecycleScope.launch { + mainActivityViewModel.pollPeriodicSyncJobStatus.collect { + Timber.d("onViewCreated: pollState Got status ${it.currentSyncJobStatus}") + when (it.currentSyncJobStatus) { + is CurrentSyncJobStatus.Running -> { + Timber.i( + "Sync: ${it.currentSyncJobStatus::class.java.simpleName} with data ${it.currentSyncJobStatus}", + ) + fadeInTopBanner(it.currentSyncJobStatus) + } + is CurrentSyncJobStatus.Succeeded -> { + val lastSyncTimestamp = + (it.currentSyncJobStatus as CurrentSyncJobStatus.Succeeded).timestamp + Timber.i( + "Sync: ${it.currentSyncJobStatus::class.java.simpleName} at $lastSyncTimestamp", + ) + patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) + mainActivityViewModel.updateLastSyncTimestamp(lastSyncTimestamp) + fadeOutTopBanner(it.currentSyncJobStatus) + } + is CurrentSyncJobStatus.Failed -> { + val lastSyncTimestamp = + (it.currentSyncJobStatus as CurrentSyncJobStatus.Failed).timestamp + Timber.i( + "Sync: ${it.currentSyncJobStatus::class.java.simpleName} at $lastSyncTimestamp}", + ) + patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) + mainActivityViewModel.updateLastSyncTimestamp(lastSyncTimestamp) + fadeOutTopBanner(it.currentSyncJobStatus) + } + is CurrentSyncJobStatus.Enqueued -> { + Timber.i("Sync: Enqueued") + patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) + fadeOutTopBanner(it.currentSyncJobStatus) + } + CurrentSyncJobStatus.Cancelled -> TODO() } } } @@ -213,7 +252,7 @@ class PatientListFragment : Fragment() { .navigate(PatientListFragmentDirections.actionPatientListToAddPatientFragment()) } - private fun fadeInTopBanner(state: SyncJobStatus) { + private fun fadeInTopBanner(state: CurrentSyncJobStatus) { if (topBanner.visibility != View.VISIBLE) { syncStatus.text = resources.getString(R.string.syncing).uppercase() syncPercent.text = "" @@ -222,25 +261,35 @@ class PatientListFragment : Fragment() { topBanner.visibility = View.VISIBLE val animation = AnimationUtils.loadAnimation(topBanner.context, R.anim.fade_in) topBanner.startAnimation(animation) - } else if (state is SyncJobStatus.InProgress) { + } else if ( + state is CurrentSyncJobStatus.Running && state.inProgressSyncJob is SyncJobStatus.InProgress + ) { + val inProgressState = state.inProgressSyncJob as? SyncJobStatus.InProgress val progress = - state - .let { it.completed.toDouble().div(it.total) } - .let { if (it.isNaN()) 0.0 else it } - .times(100) - .roundToInt() - "$progress% ${state.syncOperation.name.lowercase()}ed".also { syncPercent.text = it } - syncProgress.progress = progress + inProgressState + ?.let { it.completed.toDouble().div(it.total) } + ?.let { if (it.isNaN()) 0.0 else it } + ?.times(100) + ?.roundToInt() + "$progress% ${inProgressState?.syncOperation?.name?.lowercase()}ed" + .also { syncPercent.text = it } + syncProgress.progress = progress ?: 0 } } - private fun fadeOutTopBanner(state: SyncJobStatus) { - if (state is SyncJobStatus.Finished) syncPercent.text = "" - syncProgress.visibility = View.GONE + private fun fadeOutTopBanner(state: CurrentSyncJobStatus) { + fadeOutTopBanner(state::class.java.simpleName.uppercase()) + } + + private fun fadeOutTopBanner(state: LastSyncJobStatus) { + fadeOutTopBanner(state::class.java.simpleName.uppercase()) + } + private fun fadeOutTopBanner(statusText: String) { + syncPercent.text = "" + syncProgress.visibility = View.GONE if (topBanner.visibility == View.VISIBLE) { - "${resources.getString(R.string.sync).uppercase()} ${state::class.java.simpleName.uppercase()}" - .also { syncStatus.text = it } + "${resources.getString(R.string.sync).uppercase()} $statusText".also { syncStatus.text = it } val animation = AnimationUtils.loadAnimation(topBanner.context, R.anim.fade_out) topBanner.startAnimation(animation) diff --git a/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt b/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt index 59eed58d1b..199a558026 100644 --- a/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt +++ b/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.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,6 +22,7 @@ import androidx.benchmark.junit4.measureRepeated import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SdkSuppress +import androidx.work.Data import androidx.work.ListenableWorker import androidx.work.WorkerParameters import androidx.work.testing.TestListenableWorkerBuilder @@ -138,7 +139,12 @@ class FhirSyncWorkerBenchmark { private fun oneTimeSync(numberPatients: Int, numberObservations: Int, numberEncounters: Int) = runBlocking { val context: Context = ApplicationProvider.getApplicationContext() - val worker = TestListenableWorkerBuilder<BenchmarkTestOneTimeSyncWorker>(context).build() + val inputData = + Data.Builder() + .putString("sync_status_preferences_datastore_key", "BenchmarkTestOneTimeSyncWorker") + .build() + val worker = + TestListenableWorkerBuilder<BenchmarkTestOneTimeSyncWorker>(context, inputData).build() setupMockServerDispatcher(numberPatients, numberObservations, numberEncounters) benchmarkRule.measureRepeated { runBlocking { diff --git a/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt b/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt index 9ee6a01935..2aaa61c386 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.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,6 +28,7 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.upload.UploadStrategy import com.google.android.fhir.testing.TestDataSourceImpl import com.google.android.fhir.testing.TestDownloadManagerImpl +import com.google.android.fhir.testing.TestFailingDatasource import com.google.android.fhir.testing.TestFhirEngineImpl import com.google.common.truth.Truth.assertThat import java.util.concurrent.TimeUnit @@ -36,18 +37,21 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.runBlocking -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith +/** + * Note : If you are running these tests on a local machine in Android Studio, make sure to clear + * the storage and cache of the `com.google.android.fhir.test` app on the emulator/device before + * running each test individually. + */ @OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) -@Ignore("Flaky/fails due to https://github.com/google/android-fhir/issues/2046") class SyncInstrumentedTest { private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - class TestSyncWorker(appContext: Context, workerParams: WorkerParameters) : + open class TestSyncWorker(appContext: Context, workerParams: WorkerParameters) : FhirSyncWorker(appContext, workerParams) { override fun getFhirEngine(): FhirEngine = TestFhirEngineImpl @@ -61,6 +65,11 @@ class SyncInstrumentedTest { override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut } + class TestSyncWorkerForDownloadFailing(appContext: Context, workerParams: WorkerParameters) : + TestSyncWorker(appContext, workerParams) { + override fun getDataSource(): DataSource = TestFailingDatasource + } + @Test fun oneTime_worker_runs() { WorkManagerTestInitHelper.initializeTestWorkManager(context) @@ -68,8 +77,8 @@ class SyncInstrumentedTest { runBlocking { Sync.oneTimeSync<TestSyncWorker>(context = context) .transformWhile { - emit(it is SyncJobStatus.Finished) - it !is SyncJobStatus.Finished + emit(it is CurrentSyncJobStatus.Succeeded) + it !is CurrentSyncJobStatus.Succeeded } .shareIn(this, SharingStarted.Eagerly, 5) } @@ -78,6 +87,101 @@ class SyncInstrumentedTest { .isEqualTo(WorkInfo.State.SUCCEEDED) } + @Test + fun oneTime_worker_syncState() { + WorkManagerTestInitHelper.initializeTestWorkManager(context) + val states = mutableListOf<CurrentSyncJobStatus>() + runBlocking { + Sync.oneTimeSync<TestSyncWorker>(context = context) + .transformWhile { + states.add(it) + emit(it is CurrentSyncJobStatus.Succeeded) + it !is CurrentSyncJobStatus.Succeeded + } + .shareIn(this, SharingStarted.Eagerly, 5) + } + assertThat(states.first()).isInstanceOf(CurrentSyncJobStatus.Running::class.java) + assertThat(states.last()).isInstanceOf(CurrentSyncJobStatus.Succeeded::class.java) + } + + @Test + fun oneTime_worker_failedSyncState() { + WorkManagerTestInitHelper.initializeTestWorkManager(context) + val states = mutableListOf<CurrentSyncJobStatus>() + runBlocking { + Sync.oneTimeSync<TestSyncWorkerForDownloadFailing>(context = context) + .transformWhile { + states.add(it) + emit(it is CurrentSyncJobStatus.Failed) + it !is CurrentSyncJobStatus.Failed + } + .shareIn(this, SharingStarted.Eagerly, 5) + } + assertThat(states.first()).isInstanceOf(CurrentSyncJobStatus.Running::class.java) + assertThat(states.last()).isInstanceOf(CurrentSyncJobStatus.Failed::class.java) + } + + @Test + fun periodic_worker_periodicSyncState() { + WorkManagerTestInitHelper.initializeTestWorkManager(context) + val states = mutableListOf<PeriodicSyncJobStatus>() + // run and wait for periodic worker to finish + runBlocking { + Sync.periodicSync<TestSyncWorker>( + context = context, + periodicSyncConfiguration = + PeriodicSyncConfiguration( + syncConstraints = Constraints.Builder().build(), + repeat = RepeatInterval(interval = 15, timeUnit = TimeUnit.MINUTES), + ), + ) + .transformWhile { + states.add(it) + emit(it) + it.currentSyncJobStatus !is CurrentSyncJobStatus.Succeeded + } + .shareIn(this, SharingStarted.Eagerly, 5) + } + + assertThat(states.first().currentSyncJobStatus) + .isInstanceOf(CurrentSyncJobStatus.Running::class.java) + assertThat(states.first().lastSyncJobStatus).isNull() + assertThat(states.last().currentSyncJobStatus) + .isInstanceOf(CurrentSyncJobStatus.Succeeded::class.java) + assertThat(states.last().lastSyncJobStatus) + .isInstanceOf(LastSyncJobStatus.Succeeded::class.java) + } + + @Test + fun periodic_worker_failedPeriodicSyncState() { + WorkManagerTestInitHelper.initializeTestWorkManager(context) + val states = mutableListOf<PeriodicSyncJobStatus>() + // run and wait for periodic worker to finish + runBlocking { + Sync.periodicSync<TestSyncWorkerForDownloadFailing>( + context = context, + periodicSyncConfiguration = + PeriodicSyncConfiguration( + syncConstraints = Constraints.Builder().build(), + repeat = RepeatInterval(interval = 15, timeUnit = TimeUnit.MINUTES), + ), + ) + .transformWhile { + states.add(it) + emit(it) + it.currentSyncJobStatus !is CurrentSyncJobStatus.Failed + } + .shareIn(this, SharingStarted.Eagerly, 5) + } + + assertThat(states.first().currentSyncJobStatus) + .isInstanceOf(CurrentSyncJobStatus.Running::class.java) + assertThat(states.first().lastSyncJobStatus).isNull() + assertThat(states.last().currentSyncJobStatus) + .isInstanceOf(CurrentSyncJobStatus.Failed::class.java) + assertThat(states.last().lastSyncJobStatus).isInstanceOf(LastSyncJobStatus.Failed::class.java) + } + @Test fun periodic_worker_still_queued_to_run_after_oneTime_worker_started() { WorkManagerTestInitHelper.initializeTestWorkManager(context) @@ -94,7 +198,7 @@ class SyncInstrumentedTest { ) .transformWhile { emit(it) - it !is SyncJobStatus.Finished + it.currentSyncJobStatus !is CurrentSyncJobStatus.Succeeded } .shareIn(this, SharingStarted.Eagerly, 5) } @@ -110,7 +214,7 @@ class SyncInstrumentedTest { Sync.oneTimeSync<TestSyncWorker>(context = context) .transformWhile { emit(it) - it !is SyncJobStatus.Finished + it !is CurrentSyncJobStatus.Succeeded } .shareIn(this, SharingStarted.Eagerly, 5) } diff --git a/engine/src/main/java/com/google/android/fhir/DatastoreUtil.kt b/engine/src/main/java/com/google/android/fhir/DatastoreUtil.kt deleted file mode 100644 index c3d972c7c0..0000000000 --- a/engine/src/main/java/com/google/android/fhir/DatastoreUtil.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2022 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.fhir - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import java.time.OffsetDateTime -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking - -private val Context.dataStore: DataStore<Preferences> by - preferencesDataStore(name = "FHIR_ENGINE_PREF_DATASTORE") - -internal class DatastoreUtil(private val context: Context) { - private val lastSyncTimestampKey by lazy { stringPreferencesKey("LAST_SYNC_TIMESTAMP") } - - fun readLastSyncTimestamp(): OffsetDateTime? { - val millis = runBlocking { context.dataStore.data.first()[lastSyncTimestampKey] } ?: return null - - return OffsetDateTime.parse(millis) - } - - fun writeLastSyncTimestamp(datetime: OffsetDateTime) { - runBlocking { - context.dataStore.edit { pref -> pref[lastSyncTimestampKey] = datetime.toString() } - } - } -} diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt b/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt index 37b0c6c885..b3775f8165 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.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. @@ -19,6 +19,7 @@ package com.google.android.fhir import android.content.Context import com.google.android.fhir.DatabaseErrorStrategy.UNSPECIFIED import com.google.android.fhir.sync.DataSource +import com.google.android.fhir.sync.FhirDataStore import com.google.android.fhir.sync.HttpAuthenticator import com.google.android.fhir.sync.remote.HttpLogger import java.io.File @@ -32,7 +33,8 @@ object FhirEngineProvider { /** * Initializes the [FhirEngine] singleton with a custom Configuration. * - * This method throws [IllegalStateException] if it is called multiple times + * This method throws [IllegalStateException] if it is called multiple times. It throws + * [NullPointerException] if [FhirEngineConfiguration.context] is null. */ @Synchronized fun init(fhirEngineConfiguration: FhirEngineConfiguration) { @@ -59,6 +61,12 @@ object FhirEngineProvider { return getOrCreateFhirService(context).remoteDataSource } + @PublishedApi + @Synchronized + internal fun getFhirDataStore(context: Context): FhirDataStore { + return getOrCreateFhirService(context).fhirDataStore + } + @Synchronized private fun getOrCreateFhirService(context: Context): FhirServices { if (fhirServices == null) { 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 2f2e3c7ca6..600bf0790b 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirServices.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirServices.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import com.google.android.fhir.impl.FhirEngineImpl import com.google.android.fhir.index.ResourceIndexer import com.google.android.fhir.index.SearchParamDefinitionsProviderImpl import com.google.android.fhir.sync.DataSource +import com.google.android.fhir.sync.FhirDataStore import com.google.android.fhir.sync.remote.FhirHttpDataSource import com.google.android.fhir.sync.remote.RetrofitHttpService import org.hl7.fhir.r4.model.SearchParameter @@ -39,6 +40,7 @@ internal data class FhirServices( val parser: IParser, val database: Database, val remoteDataSource: DataSource? = null, + val fhirDataStore: FhirDataStore, ) { class Builder(private val context: Context) { private var inMemory: Boolean = false @@ -98,6 +100,7 @@ internal data class FhirServices( parser = parser, database = db, remoteDataSource = remoteDataSource, + fhirDataStore = FhirDataStore(context), ) } } diff --git a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt index c1519cd4a8..0c105f1cef 100644 --- a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt @@ -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. @@ -17,8 +17,8 @@ package com.google.android.fhir.impl import android.content.Context -import com.google.android.fhir.DatastoreUtil import com.google.android.fhir.FhirEngine +import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.LocalChange import com.google.android.fhir.SearchResult import com.google.android.fhir.db.Database @@ -67,7 +67,7 @@ internal class FhirEngineImpl(private val database: Database, private val contex } override suspend fun getLastSyncTimeStamp(): OffsetDateTime? { - return DatastoreUtil(context).readLastSyncTimestamp() + return FhirEngineProvider.getFhirDataStore(context).readLastSyncTimestamp() } override suspend fun clearDatabase() { diff --git a/engine/src/main/java/com/google/android/fhir/sync/Config.kt b/engine/src/main/java/com/google/android/fhir/sync/Config.kt index 194890b2c5..8eaf884162 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Config.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Config.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. @@ -34,6 +34,8 @@ typealias ParamMap = Map<String, String> /** Constant for the Greater Than Search Prefix */ @PublishedApi internal const val GREATER_THAN_PREFIX = "gt" +@PublishedApi internal const val UNIQUE_WORK_NAME = "unique_work_name" + val defaultRetryConfiguration = RetryConfiguration(BackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.SECONDS), 3) diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirDataStore.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirDataStore.kt new file mode 100644 index 0000000000..5cb1245796 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirDataStore.kt @@ -0,0 +1,107 @@ +/* + * 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. + * 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.sync + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import java.io.IOException +import java.time.OffsetDateTime +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import timber.log.Timber + +@PublishedApi +internal class FhirDataStore(context: Context) { + private val Context.dataStore by + preferencesDataStore( + name = FHIR_PREFERENCES_NAME, + ) + private val dataStore = context.dataStore + private val serializer = SyncJobStatus.SyncJobStatusSerializer() + private val syncJobStatusFlowMap = mutableMapOf<String, Flow<SyncJobStatus?>>() + private val lastSyncTimestampKey by lazy { stringPreferencesKey(LAST_SYNC_TIMESTAMP) } + + /** + * Observes the sync job terminal state for a given key and provides it as a Flow. + * + * @param key The key associated with the sync job. + * @return A Flow of [SyncJobStatus] representing the terminal state of the sync job, or null if + * the state is not allowed. + */ + @PublishedApi + internal fun observeTerminalSyncJobStatus(key: String): Flow<SyncJobStatus?> = + syncJobStatusFlowMap.getOrPut(key) { + dataStore.data + .catch { exception -> + if (exception is IOException) { + Timber.e(exception) + emit(emptyPreferences()) + } else { + Timber.e(exception) + throw exception + } + } + .map { preferences -> serializer.deserialize(preferences[stringPreferencesKey(key)]) } + } + + /** + * Edits the DataStore to store synchronization job status. It creates a data object containing + * the state type and serialized state of the synchronization job status. The edited preferences + * are updated with the serialized data. + * + * @param syncJobStatus The synchronization job status to be stored. + * @param key The key associated with the data to edit. + */ + internal suspend fun writeTerminalSyncJobStatus( + key: String, + syncJobStatus: SyncJobStatus, + ) { + when (syncJobStatus) { + is SyncJobStatus.Succeeded, + is SyncJobStatus.Failed, -> { + writeSyncJobStatus(key, syncJobStatus) + } + else -> error("Do not write non-terminal state") + } + } + + private suspend fun writeSyncJobStatus(key: String, syncJobStatus: SyncJobStatus) { + dataStore.edit { preferences -> + preferences[stringPreferencesKey(key)] = serializer.serialize(syncJobStatus) + } + } + + internal fun readLastSyncTimestamp(): OffsetDateTime? { + val millis = runBlocking { dataStore.data.first()[lastSyncTimestampKey] } ?: return null + return OffsetDateTime.parse(millis) + } + + internal fun writeLastSyncTimestamp(datetime: OffsetDateTime) { + runBlocking { dataStore.edit { pref -> pref[lastSyncTimestampKey] = datetime.toString() } } + } + + companion object { + private const val FHIR_PREFERENCES_NAME = "FHIR_ENGINE_PREF_DATASTORE" + private const val LAST_SYNC_TIMESTAMP = "LAST_SYNC_TIMESTAMP" + } +} diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt index a43e9d4172..bc07ddc12a 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.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. @@ -72,7 +72,6 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter val synchronizer = FhirSynchronizer( - applicationContext, getFhirEngine(), UploadConfiguration( Uploader( @@ -86,16 +85,28 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter DownloaderImpl(dataSource, getDownloadWorkManager()), getConflictResolver(), ), + FhirEngineProvider.getFhirDataStore(applicationContext), ) val job = CoroutineScope(Dispatchers.IO).launch { - synchronizer.syncState.collect { - // now send Progress to work manager so caller app can listen - setProgress(buildWorkData(it)) - - if (it is SyncJobStatus.Finished || it is SyncJobStatus.Failed) { - this@launch.cancel() + val fhirDataStore = FhirEngineProvider.getFhirDataStore(applicationContext) + synchronizer.syncState.collect { syncJobStatus -> + val uniqueWorkerName = inputData.getString(UNIQUE_WORK_NAME) + when (syncJobStatus) { + is SyncJobStatus.Succeeded, + is SyncJobStatus.Failed, -> { + // While creating periodicSync request if + // putString(SYNC_STATUS_PREFERENCES_DATASTORE_KEY, uniqueWorkName) is not present, + // then inputData.getString(SYNC_STATUS_PREFERENCES_DATASTORE_KEY) can be null. + if (uniqueWorkerName != null) { + fhirDataStore.writeTerminalSyncJobStatus(uniqueWorkerName, syncJobStatus) + } + cancel() + } + else -> { + setProgress(buildWorkData(syncJobStatus)) + } } } } @@ -106,8 +117,6 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter // await/join is needed to collect states completely kotlin.runCatching { job.join() }.onFailure(Timber::w) - setProgress(output) - Timber.d("Received result from worker $result and sending output $output") /** @@ -116,7 +125,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter */ val retries = inputData.getInt(MAX_RETRIES_ALLOWED, 0) return when (result) { - is SyncJobStatus.Finished -> Result.success(output) + is SyncJobStatus.Succeeded -> Result.success(output) else -> { if (retries > runAttemptCount) Result.retry() else Result.failure(output) } diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt index 2959092d83..e1b300884d 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.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. @@ -16,8 +16,6 @@ package com.google.android.fhir.sync -import android.content.Context -import com.google.android.fhir.DatastoreUtil import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.download.DownloadState import com.google.android.fhir.sync.download.Downloader @@ -55,17 +53,15 @@ internal class DownloadConfiguration( /** Class that helps synchronize the data source and save it in the local database */ internal class FhirSynchronizer( - context: Context, private val fhirEngine: FhirEngine, private val uploadConfiguration: UploadConfiguration, private val downloadConfiguration: DownloadConfiguration, + private val datastoreUtil: FhirDataStore, ) { private val _syncState = MutableSharedFlow<SyncJobStatus>() val syncState: SharedFlow<SyncJobStatus> = _syncState - private val datastoreUtil = DatastoreUtil(context) - private suspend fun setSyncState(state: SyncJobStatus) = _syncState.emit(state) private suspend fun setSyncState(result: SyncResult): SyncJobStatus { @@ -74,7 +70,7 @@ internal class FhirSynchronizer( val state = when (result) { - is SyncResult.Success -> SyncJobStatus.Finished + is SyncResult.Success -> SyncJobStatus.Succeeded() is SyncResult.Error -> SyncJobStatus.Failed(result.exceptions) } @@ -83,7 +79,7 @@ internal class FhirSynchronizer( } suspend fun synchronize(): SyncJobStatus { - setSyncState(SyncJobStatus.Started) + setSyncState(SyncJobStatus.Started()) return listOf(download(), upload()) .filterIsInstance<SyncResult.Error>() diff --git a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt index 584d76e2dc..3563bc687a 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,16 +23,26 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkInfo.State.CANCELLED +import androidx.work.WorkInfo.State.ENQUEUED +import androidx.work.WorkInfo.State.RUNNING import androidx.work.WorkManager import androidx.work.hasKeyWithValueOfType -import com.google.android.fhir.DatastoreUtil +import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.OffsetDateTimeTypeAdapter +import com.google.android.fhir.sync.CurrentSyncJobStatus.Cancelled +import com.google.android.fhir.sync.CurrentSyncJobStatus.Enqueued +import com.google.android.fhir.sync.CurrentSyncJobStatus.Failed +import com.google.android.fhir.sync.CurrentSyncJobStatus.Running +import com.google.android.fhir.sync.CurrentSyncJobStatus.Succeeded import com.google.gson.Gson import com.google.gson.GsonBuilder import java.time.OffsetDateTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.mapNotNull @@ -49,21 +59,23 @@ object Sync { * the same [FhirSyncWorker] to retrieve the status of the job. * * @param retryConfiguration configuration to guide the retry mechanism, or `null` to stop retry. - * @return a [Flow] of [SyncJobStatus] + * @return a [Flow] of [CurrentSyncJobStatus] */ inline fun <reified W : FhirSyncWorker> oneTimeSync( context: Context, retryConfiguration: RetryConfiguration? = defaultRetryConfiguration, - ): Flow<SyncJobStatus> { + ): Flow<CurrentSyncJobStatus> { val uniqueWorkName = "${W::class.java.name}-oneTimeSync" val flow = getWorkerInfo(context, uniqueWorkName) + val oneTimeWorkRequest = + createOneTimeWorkRequest(retryConfiguration, W::class.java, uniqueWorkName) WorkManager.getInstance(context) .enqueueUniqueWork( uniqueWorkName, ExistingWorkPolicy.KEEP, - createOneTimeWorkRequest(retryConfiguration, W::class.java), + oneTimeWorkRequest, ) - return flow + return combineSyncStateForOneTimeSync(context, uniqueWorkName, flow) } /** @@ -74,44 +86,114 @@ object Sync { * * @param periodicSyncConfiguration configuration to determine the sync frequency and retry * mechanism - * @return a [Flow] of [SyncJobStatus] + * @return a [Flow] of [PeriodicSyncJobStatus] */ @ExperimentalCoroutinesApi inline fun <reified W : FhirSyncWorker> periodicSync( context: Context, periodicSyncConfiguration: PeriodicSyncConfiguration, - ): Flow<SyncJobStatus> { + ): Flow<PeriodicSyncJobStatus> { val uniqueWorkName = "${W::class.java.name}-periodicSync" val flow = getWorkerInfo(context, uniqueWorkName) + val periodicWorkRequest = + createPeriodicWorkRequest(periodicSyncConfiguration, W::class.java, uniqueWorkName) WorkManager.getInstance(context) .enqueueUniquePeriodicWork( uniqueWorkName, ExistingPeriodicWorkPolicy.KEEP, - createPeriodicWorkRequest(periodicSyncConfiguration, W::class.java), + periodicWorkRequest, ) - return flow + return combineSyncStateForPeriodicSync(context, uniqueWorkName, flow) } /** Gets the worker info for the [FhirSyncWorker] */ - fun getWorkerInfo(context: Context, workName: String) = + @PublishedApi + internal fun getWorkerInfo(context: Context, workName: String) = WorkManager.getInstance(context) .getWorkInfosForUniqueWorkLiveData(workName) .asFlow() .flatMapConcat { it.asFlow() } .mapNotNull { workInfo -> - workInfo.progress - .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType<String>("StateType") } - ?.let { - val state = it.getString("StateType")!! - val stateData = it.getString("State") - gson.fromJson(stateData, Class.forName(state)) as SyncJobStatus - } + workInfo.state to + workInfo.progress + .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType<String>("StateType") } + ?.let { + val state = it.getString("StateType")!! + val stateData = it.getString("State") + gson.fromJson(stateData, Class.forName(state)) as SyncJobStatus + } } + /** + * Combines the sync state for a periodic sync operation, including work state, progress, and + * terminal states. + * + * @param context The Android application context. + * @param workName The name of the periodic sync work. + * @param syncJobProgressStateFlow A flow representing the progress of the sync job. + * @return A flow of [PeriodicSyncJobStatus] combining the sync job states. + */ + @PublishedApi + internal fun combineSyncStateForPeriodicSync( + context: Context, + workName: String, + workerInfoSyncJobStatusPairFromWorkManagerFlow: Flow<Pair<WorkInfo.State, SyncJobStatus?>>, + ): Flow<PeriodicSyncJobStatus> { + val syncJobStatusInDataStoreFlow: Flow<SyncJobStatus?> = + FhirEngineProvider.getFhirDataStore(context).observeTerminalSyncJobStatus(workName) + return combine(workerInfoSyncJobStatusPairFromWorkManagerFlow, syncJobStatusInDataStoreFlow) { + workerInfoSyncJobStatusPairFromWorkManager, + syncJobStatusFromDataStore, + -> + PeriodicSyncJobStatus( + lastSyncJobStatus = mapSyncJobStatusToResult(syncJobStatusFromDataStore), + currentSyncJobStatus = + createSyncState( + WorkRequest.PERIODIC, + workerInfoSyncJobStatusPairFromWorkManager.first, + workerInfoSyncJobStatusPairFromWorkManager.second, + syncJobStatusFromDataStore, + ), + ) + } + } + + /** + * Combines the sync state for a one-time sync operation, including work state, progress, and + * terminal states. + * + * @param context The Android application context. + * @param workName The name of the one-time sync work. + * @param syncJobProgressStateFlow A flow representing the progress of the sync job. + * @return A flow of [CurrentSyncJobStatus] combining the sync job states. + */ + @PublishedApi + internal fun combineSyncStateForOneTimeSync( + context: Context, + workName: String, + workerInfoSyncJobStatusPairFromWorkManagerFlow: Flow<Pair<WorkInfo.State, SyncJobStatus?>>, + ): Flow<CurrentSyncJobStatus> { + val syncJobStatusInDataStoreFlow: Flow<SyncJobStatus?> = + FhirEngineProvider.getFhirDataStore(context).observeTerminalSyncJobStatus(workName) + + return combine(workerInfoSyncJobStatusPairFromWorkManagerFlow, syncJobStatusInDataStoreFlow) { + workerInfoSyncJobStatusPairFromWorkManager, + syncJobStatusFromDataStore, + -> + createSyncState( + WorkRequest.ONE_TIME, + workerInfoSyncJobStatusPairFromWorkManager.first, + workerInfoSyncJobStatusPairFromWorkManager.second, + syncJobStatusFromDataStore, + ) + } + } + @PublishedApi internal inline fun <W : FhirSyncWorker> createOneTimeWorkRequest( retryConfiguration: RetryConfiguration?, clazz: Class<W>, + uniqueWorkName: String, ): OneTimeWorkRequest { val oneTimeWorkRequestBuilder = OneTimeWorkRequest.Builder(clazz) retryConfiguration?.let { @@ -121,7 +203,10 @@ object Sync { it.backoffCriteria.timeUnit, ) oneTimeWorkRequestBuilder.setInputData( - Data.Builder().putInt(MAX_RETRIES_ALLOWED, it.maxRetries).build(), + Data.Builder() + .putInt(MAX_RETRIES_ALLOWED, it.maxRetries) + .putString(UNIQUE_WORK_NAME, uniqueWorkName) + .build(), ) } return oneTimeWorkRequestBuilder.build() @@ -131,6 +216,7 @@ object Sync { internal inline fun <W : FhirSyncWorker> createPeriodicWorkRequest( periodicSyncConfiguration: PeriodicSyncConfiguration, clazz: Class<W>, + uniqueWorkName: String, ): PeriodicWorkRequest { val periodicWorkRequestBuilder = PeriodicWorkRequest.Builder( @@ -147,7 +233,10 @@ object Sync { it.backoffCriteria.timeUnit, ) periodicWorkRequestBuilder.setInputData( - Data.Builder().putInt(MAX_RETRIES_ALLOWED, it.maxRetries).build(), + Data.Builder() + .putInt(MAX_RETRIES_ALLOWED, it.maxRetries) + .putString(UNIQUE_WORK_NAME, uniqueWorkName) + .build(), ) } return periodicWorkRequestBuilder.build() @@ -155,6 +244,116 @@ object Sync { /** Gets the timestamp of the last sync job. */ fun getLastSyncTimestamp(context: Context): OffsetDateTime? { - return DatastoreUtil(context).readLastSyncTimestamp() + return FhirEngineProvider.getFhirDataStore(context).readLastSyncTimestamp() + } + + private fun createSyncState( + workRequest: WorkRequest, + workInfoState: WorkInfo.State, + syncJobStatusFromWorkManager: SyncJobStatus?, + syncJobStatusFromDataStore: SyncJobStatus?, + ): CurrentSyncJobStatus { + return when (syncJobStatusFromWorkManager) { + is SyncJobStatus.Started, + is SyncJobStatus.InProgress, -> Running(syncJobStatusFromWorkManager) + null -> { + when (workRequest) { + WorkRequest.ONE_TIME -> + handleNullWorkManagerStatusForOneTimeSync(workInfoState, syncJobStatusFromDataStore) + WorkRequest.PERIODIC -> + handleNullWorkManagerStatusForPeriodicSync(workInfoState, syncJobStatusFromDataStore) + } + } + else -> error("Inconsistent syncJobStatus: $syncJobStatusFromWorkManager.") + } + } + + /** + * Only call this API when `syncJobStatusFromWorkManager` is null. Create a [CurrentSyncJobStatus] + * from `syncJobStatusFromDataStore` if it is not null; otherwise, create it from + * [WorkInfo.State]. + */ + private fun handleNullWorkManagerStatusForOneTimeSync( + workInfoState: WorkInfo.State, + syncJobStatusFromDataStore: SyncJobStatus?, + ): CurrentSyncJobStatus = + syncJobStatusFromDataStore?.let { + when (it) { + is SyncJobStatus.Succeeded -> Succeeded(it.timestamp) + is SyncJobStatus.Failed -> Failed(it.timestamp) + else -> error("Inconsistent terminal syncJobStatus : $syncJobStatusFromDataStore") + } + } + ?: when (workInfoState) { + RUNNING -> Running(SyncJobStatus.Started()) + ENQUEUED -> Enqueued + CANCELLED -> Cancelled + // syncJobStatusFromDataStore should not be null for SUCCEEDED, FAILED. + else -> error("Inconsistent WorkInfo.State: $workInfoState.") + } + + /** + * Only call this API when syncJobStatusFromWorkManager is null. Create a [CurrentSyncJobStatus] + * from [WorkInfo.State]. (Note: syncJobStatusFromDataStore is updated as lastSynJobStatus, which + * is the terminalSyncJobStatus.) + */ + private fun handleNullWorkManagerStatusForPeriodicSync( + workInfoState: WorkInfo.State, + ): CurrentSyncJobStatus = + when (workInfoState) { + RUNNING -> Running(SyncJobStatus.Started()) + ENQUEUED -> Enqueued + CANCELLED -> Cancelled + else -> error("Inconsistent WorkInfo.State in periodic sync : $workInfoState.") + } + + private fun handleNullWorkManagerStatusForPeriodicSync( + workInfoState: WorkInfo.State, + syncJobStatusFromDataStore: SyncJobStatus?, + ): CurrentSyncJobStatus = + when (workInfoState) { + RUNNING -> Running(SyncJobStatus.Started()) + ENQUEUED -> { + syncJobStatusFromDataStore?.let { mapDataStoreSyncJobStatusToCurrentSyncJobStatus(it) } + ?: Enqueued + } + CANCELLED -> Cancelled + else -> error("Inconsistent WorkInfo.State in periodic sync : $workInfoState.") + } + + private fun mapDataStoreSyncJobStatusToCurrentSyncJobStatus( + syncJobStatusFromDataStore: SyncJobStatus, + ) = + when (syncJobStatusFromDataStore) { + is SyncJobStatus.Succeeded -> Succeeded(syncJobStatusFromDataStore.timestamp) + is SyncJobStatus.Failed -> Failed(syncJobStatusFromDataStore.timestamp) + else -> error("Inconsistent syncJobStatus in the dataStore : $syncJobStatusFromDataStore.") + } + + /** + * Maps the [lastSyncJobStatus] to a specific [LastSyncJobStatus] based on the provided status. + * + * @param lastSyncJobStatus The last synchronization job status of type [SyncJobStatus]. + * @return The mapped [LastSyncJobStatus] based on the provided [lastSyncJobStatus]: + * - [LastSyncJobStatus.Succeeded] with the timestamp if the last job status is + * [SyncJobStatus.Succeeded]. + * - [LastSyncJobStatus.Failed] with exceptions and timestamp if the last job status is + * [SyncJobStatus.Failed]. + * - `null` if the last job status is neither Finished nor Failed. + */ + private fun mapSyncJobStatusToResult( + lastSyncJobStatus: SyncJobStatus?, + ) = + lastSyncJobStatus?.let { + when (it) { + is SyncJobStatus.Succeeded -> LastSyncJobStatus.Succeeded(it.timestamp) + is SyncJobStatus.Failed -> LastSyncJobStatus.Failed(lastSyncJobStatus.timestamp) + else -> error("Inconsistent terminal syncJobStatus : $lastSyncJobStatus") + } + } + + private enum class WorkRequest { + ONE_TIME, + PERIODIC, } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt index 8c063a28df..c74644d0dc 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,13 +16,90 @@ package com.google.android.fhir.sync +import androidx.work.Data +import androidx.work.workDataOf +import com.google.android.fhir.OffsetDateTimeTypeAdapter +import com.google.gson.GsonBuilder import java.time.OffsetDateTime +/** + * Data class representing the state of a periodic synchronization operation. It is a combined state + * of [WorkInfo.State] and [SyncJobStatus]. See [CurrentSyncJobStatus] and [LastSyncJobStatus] for + * more details. + * + * @property lastSyncJobStatus The result of the last synchronization job [LastSyncJobStatus]. It + * only represents terminal states. + * @property currentSyncJobStatus The current state of the synchronization job + * [CurrentSyncJobStatus]. + */ +data class PeriodicSyncJobStatus( + val lastSyncJobStatus: LastSyncJobStatus?, + val currentSyncJobStatus: CurrentSyncJobStatus, +) + +/** + * Sealed class representing the result of a synchronization operation. These are terminal states of + * the sync operation, representing [Succeeded] and [Failed]. + * + * @property timestamp The timestamp when the synchronization result occurred. + */ +sealed class LastSyncJobStatus(val timestamp: OffsetDateTime) { + /** Represents a successful synchronization result. */ + class Succeeded(timestamp: OffsetDateTime) : LastSyncJobStatus(timestamp) + + /** Represents a failed synchronization result. */ + class Failed(timestamp: OffsetDateTime) : LastSyncJobStatus(timestamp) +} + +/** + * Sealed class representing different states of a synchronization operation. It combines + * [WorkInfo.State] and [SyncJobStatus]. Enqueued state represents [WorkInfo.State.ENQUEUED] where + * [SyncJobStatus] is not applicable. Running state is a combined state of [WorkInfo.State.ENQUEUED] + * and [SyncJobStatus.Started] or [SyncJobStatus.InProgress]. Succeeded state is a combined state of + * [WorkInfo.State.SUCCEEDED] and [SyncJobStatus.Started] or [SyncJobStatus.Succeeded]. Failed state + * is a combined state of [WorkInfo.State.FAILED] and [SyncJobStatus.Failed]. Cancelled state + * represents [WorkInfo.State.CANCELLED] where [SyncJobStatus] is not applicable. + */ +sealed class CurrentSyncJobStatus { + /** State indicating that the synchronization operation is enqueued. */ + object Enqueued : CurrentSyncJobStatus() + + /** + * State indicating that the synchronization operation is running. + * + * @param inProgressSyncJob The current status of the synchronization job. + */ + data class Running(val inProgressSyncJob: SyncJobStatus) : CurrentSyncJobStatus() + + /** + * State indicating that the synchronization operation succeeded. + * + * @param timestamp The timestamp when the synchronization result occurred. + */ + class Succeeded(val timestamp: OffsetDateTime) : CurrentSyncJobStatus() + + /** + * State indicating that the synchronization operation failed. + * + * @param timestamp The timestamp when the synchronization result occurred. + */ + class Failed(val timestamp: OffsetDateTime) : CurrentSyncJobStatus() + + /** State indicating that the synchronization operation is canceled. */ + object Cancelled : CurrentSyncJobStatus() +} + +/** + * Sealed class representing different states of a synchronization operation. These states do not + * represent [WorkInfo.State], whereas [CurrentSyncJobStatus] combines [WorkInfo.State] and + * [SyncJobStatus] in one-time and periodic sync. For more details, see [CurrentSyncJobStatus] and + * [PeriodicSyncJobStatus]. + */ sealed class SyncJobStatus { val timestamp: OffsetDateTime = OffsetDateTime.now() /** Sync job has been started on the client but the syncing is not necessarily in progress. */ - object Started : SyncJobStatus() + class Started : SyncJobStatus() /** Syncing in progress with the server. */ data class InProgress( @@ -32,8 +109,73 @@ sealed class SyncJobStatus { ) : SyncJobStatus() /** Sync job finished successfully. */ - object Finished : SyncJobStatus() + class Succeeded : SyncJobStatus() /** Sync job failed. */ data class Failed(val exceptions: List<ResourceSyncException>) : SyncJobStatus() + + /** Helper class for serializing and deserializing [SyncJobStatus] objects. */ + internal class SyncJobStatusSerializer { + private val serializer = + GsonBuilder() + .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeTypeAdapter().nullSafe()) + .setExclusionStrategies(FhirSyncWorker.StateExclusionStrategy()) + .create() + + private val allowedSyncJobStatusPackages = + listOf( + AllowedSyncJobStatus.SUCCEEDED.allowedPackage, + AllowedSyncJobStatus.FAILED.allowedPackage, + ) + + /** + * Deserializes the given data string into a [SyncJobStatus] object. + * + * @param data The serialized data string. + * @return The deserialized [SyncJobStatus] object, or null if the deserialization fails or the + * data is not of an allowed class. + */ + fun deserialize(data: String?): SyncJobStatus? { + return serializer.fromJson(data, Data::class.java)?.let { + val stateType = it.getString(STATE_TYPE) + val stateData = it.getString(STATE) + if (stateType?.isAllowedClass() == true) { + stateData?.let { stateData -> + serializer.fromJson(stateData, Class.forName(stateType)) as? SyncJobStatus + } + } else { + error("Corrupt state type : $stateType") + } + } + } + + /** + * Serializes the given [SyncJobStatus] object into a JSON-formatted string. + * + * @param syncJobStatus The [SyncJobStatus] object to serialize. + * @return The JSON-formatted string representing the serialized [SyncJobStatus] object. + */ + fun serialize(syncJobStatus: SyncJobStatus): String { + val data = + workDataOf( + STATE_TYPE to syncJobStatus::class.java.name, + STATE to serializer.toJson(syncJobStatus), + ) + return serializer.toJson(data) + } + + private fun String.isAllowedClass(): Boolean { + return allowedSyncJobStatusPackages.any { this.startsWith(it) } + } + + private companion object { + private const val STATE_TYPE = "STATE_TYPE" + private const val STATE = "STATE" + } + } + + private enum class AllowedSyncJobStatus(val allowedPackage: String) { + SUCCEEDED(SyncJobStatus.Succeeded::class.java.name), + FAILED(SyncJobStatus.Failed::class.java.name), + } } diff --git a/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt b/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt index 117357b0f0..591d7a4eda 100644 --- a/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt +++ b/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Google LLC + * Copyright 2021-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. @@ -52,7 +52,11 @@ class FhirEngineProviderTest { @Test fun build_twiceWithAppContext_afterCleanup_shouldReturnDifferentInstances() { - provider.init(FhirEngineConfiguration(testMode = true)) + provider.init( + FhirEngineConfiguration( + testMode = true, + ), + ) val engineOne = provider.getInstance(ApplicationProvider.getApplicationContext()) provider.cleanup() val engineTwo = provider.getInstance(ApplicationProvider.getApplicationContext()) @@ -61,7 +65,11 @@ class FhirEngineProviderTest { @Test fun cleanup_not_in_test_mode_fails() { - provider.init(FhirEngineConfiguration(testMode = false)) + provider.init( + FhirEngineConfiguration( + testMode = false, + ), + ) provider.getInstance(ApplicationProvider.getApplicationContext()) diff --git a/engine/src/test/java/com/google/android/fhir/sync/FhirDataStoreTest.kt b/engine/src/test/java/com/google/android/fhir/sync/FhirDataStoreTest.kt new file mode 100644 index 0000000000..7b10e4e41c --- /dev/null +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirDataStoreTest.kt @@ -0,0 +1,46 @@ +/* + * 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. + * 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.sync + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class FhirDataStoreTest { + private val fhirDataStore = FhirDataStore(ApplicationProvider.getApplicationContext()) + + @Test + fun observeSyncJobTerminalState() = runBlocking { + val key = "key" + val editJob = launch { + fhirDataStore.writeTerminalSyncJobStatus(key, SyncJobStatus.Succeeded()) + fhirDataStore.observeTerminalSyncJobStatus(key).collect { + assertTrue(it is SyncJobStatus.Succeeded) + this.cancel() + } + } + editJob.join() + } +} diff --git a/engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.kt index f680bc8eb5..e911d557fc 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirSyncWorkerTest.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. @@ -136,8 +136,8 @@ class FhirSyncWorkerTest { val worker = TestListenableWorkerBuilder<FailingPeriodicSyncWorker>( context, - inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 2).build(), - runAttemptCount = 1, + inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 1).build(), + runAttemptCount = 0, ) .build() val result = runBlocking { worker.doWork() } @@ -149,7 +149,7 @@ class FhirSyncWorkerTest { val worker = TestListenableWorkerBuilder<FailingPeriodicSyncWorkerWithoutDataSource>( context, - inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 2).build(), + inputData = Data.Builder().putInt(MAX_RETRIES_ALLOWED, 1).build(), runAttemptCount = 2, ) .build() diff --git a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt index eb44c6cf9d..e978562320 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.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. @@ -16,7 +16,6 @@ package com.google.android.fhir.sync -import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.sync.download.DownloadState import com.google.android.fhir.sync.download.Downloader @@ -50,6 +49,8 @@ class FhirSynchronizerTest { @Mock private lateinit var conflictResolver: ConflictResolver + @Mock private lateinit var fhirDataStore: FhirDataStore + private lateinit var fhirSynchronizer: FhirSynchronizer @Before @@ -57,10 +58,10 @@ class FhirSynchronizerTest { MockitoAnnotations.openMocks(this) fhirSynchronizer = FhirSynchronizer( - ApplicationProvider.getApplicationContext(), TestFhirEngineImpl, UploadConfiguration(uploader), DownloadConfiguration(downloader, conflictResolver), + fhirDataStore, ) } @@ -80,16 +81,16 @@ class FhirSynchronizerTest { val result = fhirSynchronizer.synchronize() - assertThat(emittedValues) - .containsExactly( - SyncJobStatus.Started, - SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, total = 10, completed = 10), - SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0), - SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 1), - SyncJobStatus.Finished, - ) + assertThat(emittedValues[0]).isInstanceOf(SyncJobStatus.Started::class.java) + assertThat(emittedValues[1]) + .isEqualTo(SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, total = 10, completed = 10)) + assertThat(emittedValues[2]) + .isEqualTo(SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0)) + assertThat(emittedValues[3]) + .isEqualTo(SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 1)) + assertThat(emittedValues[4]).isInstanceOf(SyncJobStatus.Succeeded::class.java) - assertThat(SyncJobStatus.Finished::class.java).isEqualTo(result::class.java) + assertThat(SyncJobStatus.Succeeded::class.java).isEqualTo(result::class.java) } @Test @@ -109,14 +110,12 @@ class FhirSynchronizerTest { val result = fhirSynchronizer.synchronize() - assertThat(emittedValues) - .containsExactly( - SyncJobStatus.Started, - SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0), - SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 1), - SyncJobStatus.Failed(exceptions = listOf(error)), - ) - + assertThat(emittedValues[0]).isInstanceOf(SyncJobStatus.Started::class.java) + assertThat(emittedValues[1]) + .isEqualTo(SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0)) + assertThat(emittedValues[2]) + .isEqualTo(SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 1)) + assertThat(emittedValues[3]).isEqualTo(SyncJobStatus.Failed(exceptions = listOf(error))) assertThat(result).isInstanceOf(SyncJobStatus.Failed::class.java) assertThat(listOf(error)).isEqualTo((result as SyncJobStatus.Failed).exceptions) } @@ -134,13 +133,12 @@ class FhirSynchronizerTest { val result = fhirSynchronizer.synchronize() - assertThat(emittedValues) - .containsExactly( - SyncJobStatus.Started, - SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, total = 10, completed = 10), - SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0), - SyncJobStatus.Failed(exceptions = listOf(error)), - ) + assertThat(emittedValues[0]).isInstanceOf(SyncJobStatus.Started::class.java) + assertThat(emittedValues[1]) + .isEqualTo(SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, total = 10, completed = 10)) + assertThat(emittedValues[2]) + .isEqualTo(SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0)) + assertThat(emittedValues[3]).isEqualTo(SyncJobStatus.Failed(exceptions = listOf(error))) assertThat(result).isInstanceOf(SyncJobStatus.Failed::class.java) assertThat(listOf(error)).isEqualTo((result as SyncJobStatus.Failed).exceptions) } diff --git a/engine/src/test/java/com/google/android/fhir/sync/SyncTest.kt b/engine/src/test/java/com/google/android/fhir/sync/SyncTest.kt index bd25999aa6..8617353a75 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/SyncTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/SyncTest.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. @@ -54,6 +54,7 @@ class SyncTest { Sync.createOneTimeWorkRequest( RetryConfiguration(BackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.SECONDS), 3), PassingPeriodicSyncWorker::class.java, + "unique-name", ) assertThat(workRequest.workSpec.backoffPolicy).isEqualTo(BackoffPolicy.LINEAR) assertThat(workRequest.workSpec.backoffDelayDuration).isEqualTo(TimeUnit.SECONDS.toMillis(30)) @@ -62,7 +63,8 @@ class SyncTest { @Test fun createOneTimeWorkRequest_withoutRetryConfiguration_shouldHaveZeroMaxTries() { - val workRequest = Sync.createOneTimeWorkRequest(null, PassingPeriodicSyncWorker::class.java) + val workRequest = + Sync.createOneTimeWorkRequest(null, PassingPeriodicSyncWorker::class.java, "unique-name") assertThat(workRequest.workSpec.input.getInt(MAX_RETRIES_ALLOWED, 0)).isEqualTo(0) // Not checking [workRequest.workSpec.backoffPolicy] and // [workRequest.workSpec.backoffDelayDuration] as they have default values. @@ -78,6 +80,7 @@ class SyncTest { RetryConfiguration(BackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.SECONDS), 3), ), PassingPeriodicSyncWorker::class.java, + "unique-name", ) assertThat(workRequest.workSpec.intervalDuration).isEqualTo(TimeUnit.MINUTES.toMillis(20)) assertThat(workRequest.workSpec.backoffPolicy).isEqualTo(BackoffPolicy.LINEAR) @@ -94,6 +97,7 @@ class SyncTest { retryConfiguration = null, ), PassingPeriodicSyncWorker::class.java, + "unique-name", ) assertThat(workRequest.workSpec.intervalDuration).isEqualTo(TimeUnit.MINUTES.toMillis(20)) assertThat(workRequest.workSpec.input.getInt(MAX_RETRIES_ALLOWED, 0)).isEqualTo(0) From 4e11525e9ce362791a6638611da5210b5df90ff9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Mon, 8 Jan 2024 12:13:42 -0500 Subject: [PATCH 67/69] Dependency versions fix (#2380) * Moves the version selection from resolutionStrategy to dependencyConstraints to make sure it is exported to maven correctly. * Removes unused dependency declarations. --- buildSrc/src/main/kotlin/Dependencies.kt | 177 ++++++++++++----------- catalog/build.gradle.kts | 4 - common/build.gradle.kts | 22 ++- contrib/barcode/build.gradle.kts | 9 +- datacapture/build.gradle.kts | 15 +- demo/build.gradle.kts | 4 - engine/benchmark/build.gradle.kts | 14 -- engine/build.gradle.kts | 16 +- knowledge/build.gradle.kts | 16 +- workflow-testing/build.gradle.kts | 28 +--- workflow/benchmark/build.gradle.kts | 14 +- workflow/build.gradle.kts | 19 +-- 12 files changed, 151 insertions(+), 187 deletions(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index f5174a7b15..8427272ecc 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.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. @@ -15,6 +15,7 @@ */ import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.DependencyConstraint import org.gradle.kotlin.dsl.exclude object Dependencies { @@ -42,34 +43,53 @@ object Dependencies { } object HapiFhir { - const val fhirBase = "ca.uhn.hapi.fhir:hapi-fhir-base:${Versions.hapiFhir}" - const val fhirClient = "ca.uhn.hapi.fhir:hapi-fhir-client:${Versions.hapiFhir}" - const val structuresDstu2 = "ca.uhn.hapi.fhir:hapi-fhir-structures-dstu2:${Versions.hapiFhir}" - const val structuresDstu3 = "ca.uhn.hapi.fhir:hapi-fhir-structures-dstu3:${Versions.hapiFhir}" - const val structuresR4 = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${Versions.hapiFhir}" - const val structuresR4b = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4b:${Versions.hapiFhir}" - const val structuresR5 = "ca.uhn.hapi.fhir:hapi-fhir-structures-r5:${Versions.hapiFhir}" - - const val validation = "ca.uhn.hapi.fhir:hapi-fhir-validation:${Versions.hapiFhir}" - const val validationDstu3 = - "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-dstu3:${Versions.hapiFhir}" - const val validationR4 = - "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-r4:${Versions.hapiFhir}" - const val validationR5 = - "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-r5:${Versions.hapiFhir}" - - const val fhirCoreDstu2 = "ca.uhn.hapi.fhir:org.hl7.fhir.dstu2:${Versions.hapiFhirCore}" - const val fhirCoreDstu2016 = - "ca.uhn.hapi.fhir:org.hl7.fhir.dstu2016may:${Versions.hapiFhirCore}" - const val fhirCoreDstu3 = "ca.uhn.hapi.fhir:org.hl7.fhir.dstu3:${Versions.hapiFhirCore}" - const val fhirCoreR4 = "ca.uhn.hapi.fhir:org.hl7.fhir.r4:${Versions.hapiFhirCore}" - const val fhirCoreR4b = "ca.uhn.hapi.fhir:org.hl7.fhir.r4b:${Versions.hapiFhirCore}" - const val fhirCoreR5 = "ca.uhn.hapi.fhir:org.hl7.fhir.r5:${Versions.hapiFhirCore}" - const val fhirCoreUtils = "ca.uhn.hapi.fhir:org.hl7.fhir.utilities:${Versions.hapiFhirCore}" - const val fhirCoreConvertors = - "ca.uhn.hapi.fhir:org.hl7.fhir.convertors:${Versions.hapiFhirCore}" - - const val guavaCaching = "ca.uhn.hapi.fhir:hapi-fhir-caching-guava:${Versions.hapiFhir}" + const val fhirBaseModule = "ca.uhn.hapi.fhir:hapi-fhir-base" + const val fhirClientModule = "ca.uhn.hapi.fhir:hapi-fhir-client" + const val structuresDstu2Module = "ca.uhn.hapi.fhir:hapi-fhir-structures-dstu2" + const val structuresDstu3Module = "ca.uhn.hapi.fhir:hapi-fhir-structures-dstu3" + const val structuresR4Module = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4" + const val structuresR4bModule = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4b" + const val structuresR5Module = "ca.uhn.hapi.fhir:hapi-fhir-structures-r5" + + const val validationModule = "ca.uhn.hapi.fhir:hapi-fhir-validation" + const val validationDstu3Module = "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-dstu3" + const val validationR4Module = "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-r4" + const val validationR5Module = "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-r5" + + const val fhirCoreDstu2Module = "ca.uhn.hapi.fhir:org.hl7.fhir.dstu2" + const val fhirCoreDstu2016Module = "ca.uhn.hapi.fhir:org.hl7.fhir.dstu2016may" + const val fhirCoreDstu3Module = "ca.uhn.hapi.fhir:org.hl7.fhir.dstu3" + const val fhirCoreR4Module = "ca.uhn.hapi.fhir:org.hl7.fhir.r4" + const val fhirCoreR4bModule = "ca.uhn.hapi.fhir:org.hl7.fhir.r4b" + const val fhirCoreR5Module = "ca.uhn.hapi.fhir:org.hl7.fhir.r5" + const val fhirCoreUtilsModule = "ca.uhn.hapi.fhir:org.hl7.fhir.utilities" + const val fhirCoreConvertorsModule = "ca.uhn.hapi.fhir:org.hl7.fhir.convertors" + + const val guavaCachingModule = "ca.uhn.hapi.fhir:hapi-fhir-caching-guava" + + const val fhirBase = "$fhirBaseModule:${Versions.hapiFhir}" + const val fhirClient = "$fhirClientModule:${Versions.hapiFhir}" + const val structuresDstu2 = "$structuresDstu2Module:${Versions.hapiFhir}" + const val structuresDstu3 = "$structuresDstu3Module:${Versions.hapiFhir}" + const val structuresR4 = "$structuresR4Module:${Versions.hapiFhir}" + const val structuresR4b = "$structuresR4bModule:${Versions.hapiFhir}" + const val structuresR5 = "$structuresR5Module:${Versions.hapiFhir}" + + const val validation = "$validationModule:${Versions.hapiFhir}" + const val validationDstu3 = "$validationDstu3Module:${Versions.hapiFhir}" + const val validationR4 = "$validationR4Module:${Versions.hapiFhir}" + const val validationR5 = "$validationR5Module:${Versions.hapiFhir}" + + const val fhirCoreDstu2 = "$fhirCoreDstu2Module:${Versions.hapiFhirCore}" + const val fhirCoreDstu2016 = "$fhirCoreDstu2016Module:${Versions.hapiFhirCore}" + const val fhirCoreDstu3 = "$fhirCoreDstu3Module:${Versions.hapiFhirCore}" + const val fhirCoreR4 = "$fhirCoreR4Module:${Versions.hapiFhirCore}" + const val fhirCoreR4b = "$fhirCoreR4bModule:${Versions.hapiFhirCore}" + const val fhirCoreR5 = "$fhirCoreR5Module:${Versions.hapiFhirCore}" + const val fhirCoreUtils = "$fhirCoreUtilsModule:${Versions.hapiFhirCore}" + const val fhirCoreConvertors = "$fhirCoreConvertorsModule:${Versions.hapiFhirCore}" + + const val guavaCaching = "$guavaCachingModule:${Versions.hapiFhir}" } object Jackson { @@ -79,13 +99,14 @@ object Dependencies { private const val datatypeGroup = "$mainGroup.datatype" private const val moduleGroup = "$mainGroup.module" - const val annotations = "$coreGroup:jackson-annotations:${Versions.jackson}" - const val bom = "$mainGroup:jackson-bom:${Versions.jackson}" - const val core = "$coreGroup:jackson-core:${Versions.jacksonCore}" - const val databind = "$coreGroup:jackson-databind:${Versions.jackson}" - const val dataformatXml = "$dataformatGroup:jackson-dataformat-xml:${Versions.jackson}" - const val jaxbAnnotations = "$moduleGroup:jackson-module-jaxb-annotations:${Versions.jackson}" - const val jsr310 = "$datatypeGroup:jackson-datatype-jsr310:${Versions.jackson}" + const val annotationsBase = "$coreGroup:jackson-annotations:${Versions.jackson}" + const val bomBase = "$mainGroup:jackson-bom:${Versions.jackson}" + const val coreBase = "$coreGroup:jackson-core:${Versions.jacksonCore}" + const val databindBase = "$coreGroup:jackson-databind:${Versions.jackson}" + const val dataformatXmlBase = "$dataformatGroup:jackson-dataformat-xml:${Versions.jackson}" + const val jaxbAnnotationsBase = + "$moduleGroup:jackson-module-jaxb-annotations:${Versions.jackson}" + const val jsr310Base = "$datatypeGroup:jackson-datatype-jsr310:${Versions.jackson}" } object Kotlin { @@ -148,7 +169,10 @@ object Dependencies { const val desugarJdkLibs = "com.android.tools:desugar_jdk_libs:${Versions.desugarJdkLibs}" const val fhirUcum = "org.fhir:ucum:${Versions.fhirUcum}" const val gson = "com.google.code.gson:gson:${Versions.gson}" - const val guava = "com.google.guava:guava:${Versions.guava}" + + const val guavaModule = "com.google.guava:guava" + const val guava = "$guavaModule:${Versions.guava}" + const val httpInterceptor = "com.squareup.okhttp3:logging-interceptor:${Versions.http}" const val http = "com.squareup.okhttp3:okhttp:${Versions.http}" const val mockWebServer = "com.squareup.okhttp3:mockwebserver:${Versions.http}" @@ -231,7 +255,7 @@ object Dependencies { const val caffeine = "2.9.1" const val fhirUcum = "1.0.3" const val gson = "2.9.1" - const val guava = "32.1.2-android" + const val guava = "32.1.3-android" const val hapiFhir = "6.8.0" const val hapiFhirCore = "6.0.22" @@ -294,57 +318,38 @@ object Dependencies { exclude(group = "org.apache.httpcomponents") exclude(group = "org.antlr", module = "antlr4") exclude(group = "org.eclipse.persistence", module = "org.eclipse.persistence.moxy") - } - - fun Configuration.forceGuava() { - // Removes caffeine exclude(module = "hapi-fhir-caching-caffeine") exclude(group = "com.github.ben-manes.caffeine", module = "caffeine") - - resolutionStrategy { - force(guava) - force(HapiFhir.guavaCaching) - } } - fun Configuration.forceHapiVersion() { - // Removes newer versions of caffeine and manually imports 2.9 - // Removes newer versions of hapi and keeps on 6.0.1 - // (newer versions don't work on Android) - resolutionStrategy { - force(HapiFhir.fhirBase) - force(HapiFhir.fhirClient) - force(HapiFhir.fhirCoreConvertors) - - force(HapiFhir.fhirCoreDstu2) - force(HapiFhir.fhirCoreDstu2016) - force(HapiFhir.fhirCoreDstu3) - force(HapiFhir.fhirCoreR4) - force(HapiFhir.fhirCoreR4b) - force(HapiFhir.fhirCoreR5) - force(HapiFhir.fhirCoreUtils) - - force(HapiFhir.structuresDstu2) - force(HapiFhir.structuresDstu3) - force(HapiFhir.structuresR4) - force(HapiFhir.structuresR5) - - force(HapiFhir.validation) - force(HapiFhir.validationDstu3) - force(HapiFhir.validationR4) - force(HapiFhir.validationR5) - } - } - - fun Configuration.forceJacksonVersion() { - resolutionStrategy { - force(Jackson.annotations) - force(Jackson.bom) - force(Jackson.core) - force(Jackson.databind) - force(Jackson.jaxbAnnotations) - force(Jackson.jsr310) - force(Jackson.dataformatXml) - } + fun hapiFhirConstraints(): Map<String, DependencyConstraint.() -> Unit> { + return mutableMapOf<String, DependencyConstraint.() -> Unit>( + guavaModule to { version { strictly(Versions.guava) } }, + HapiFhir.fhirBaseModule to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.fhirClientModule to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.fhirCoreConvertorsModule to { version { strictly(Versions.hapiFhirCore) } }, + HapiFhir.fhirCoreDstu2Module to { version { strictly(Versions.hapiFhirCore) } }, + HapiFhir.fhirCoreDstu2016Module to { version { strictly(Versions.hapiFhirCore) } }, + HapiFhir.fhirCoreDstu3Module to { version { strictly(Versions.hapiFhirCore) } }, + HapiFhir.fhirCoreR4Module to { version { strictly(Versions.hapiFhirCore) } }, + HapiFhir.fhirCoreR4bModule to { version { strictly(Versions.hapiFhirCore) } }, + HapiFhir.fhirCoreR5Module to { version { strictly(Versions.hapiFhirCore) } }, + HapiFhir.fhirCoreUtilsModule to { version { strictly(Versions.hapiFhirCore) } }, + HapiFhir.structuresDstu2Module to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.structuresDstu3Module to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.structuresR4Module to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.structuresR5Module to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.validationModule to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.validationDstu3Module to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.validationR4Module to { version { strictly(Versions.hapiFhir) } }, + HapiFhir.validationR5Module to { version { strictly(Versions.hapiFhir) } }, + Jackson.annotationsBase to { version { strictly(Versions.jackson) } }, + Jackson.bomBase to { version { strictly(Versions.jackson) } }, + Jackson.coreBase to { version { strictly(Versions.jacksonCore) } }, + Jackson.databindBase to { version { strictly(Versions.jackson) } }, + Jackson.jaxbAnnotationsBase to { version { strictly(Versions.jackson) } }, + Jackson.jsr310Base to { version { strictly(Versions.jackson) } }, + Jackson.dataformatXmlBase to { version { strictly(Versions.jackson) } }, + ) } } diff --git a/catalog/build.gradle.kts b/catalog/build.gradle.kts index d137d0a320..e4bb7a4373 100644 --- a/catalog/build.gradle.kts +++ b/catalog/build.gradle.kts @@ -1,5 +1,3 @@ -import Dependencies.forceGuava - plugins { id(Plugins.BuildPlugins.application) id(Plugins.BuildPlugins.kotlinAndroid) @@ -42,8 +40,6 @@ android { kotlin { jvmToolchain(11) } } -configurations { all { forceGuava() } } - dependencies { androidTestImplementation(Dependencies.AndroidxTest.extJunit) androidTestImplementation(Dependencies.Espresso.espressoCore) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 548e463c07..7f53725c3e 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -1,3 +1,5 @@ +import Dependencies.removeIncompatibleDependencies + plugins { id(Plugins.BuildPlugins.androidLib) id(Plugins.BuildPlugins.kotlinAndroid) @@ -17,20 +19,10 @@ android { kotlin { jvmToolchain(11) } } -configurations { - all { - exclude(module = "xpp3") - exclude(module = "hapi-fhir-caching-caffeine") - exclude(group = "com.github.ben-manes.caffeine", module = "caffeine") - - resolutionStrategy { force("com.google.guava:guava:32.1.3-android") } - } -} +configurations { all { removeIncompatibleDependencies() } } dependencies { - // REVERT to DEPENDENCIES LATER - api("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.10.0") - api("ca.uhn.hapi.fhir:hapi-fhir-caching-guava:6.10.0") + api(Dependencies.HapiFhir.structuresR4) implementation(Dependencies.fhirUcum) @@ -39,4 +31,10 @@ dependencies { testImplementation(Dependencies.junit) testImplementation(Dependencies.robolectric) testImplementation(Dependencies.truth) + + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + api(libName, constraints) + } + } } diff --git a/contrib/barcode/build.gradle.kts b/contrib/barcode/build.gradle.kts index 2f847d9456..69790d9f7e 100644 --- a/contrib/barcode/build.gradle.kts +++ b/contrib/barcode/build.gradle.kts @@ -1,4 +1,4 @@ -import Dependencies.forceGuava +import Dependencies.removeIncompatibleDependencies plugins { id(Plugins.BuildPlugins.androidLib) @@ -46,12 +46,7 @@ android { kotlin { jvmToolchain(11) } } -configurations { - all { - exclude(module = "xpp3") - forceGuava() - } -} +configurations { all { removeIncompatibleDependencies() } } dependencies { androidTestImplementation(Dependencies.AndroidxTest.core) diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts index 0e829e662d..d261f17e98 100644 --- a/datacapture/build.gradle.kts +++ b/datacapture/build.gradle.kts @@ -1,6 +1,4 @@ -import Dependencies.forceGuava -import Dependencies.forceHapiVersion -import Dependencies.forceJacksonVersion +import Dependencies.removeIncompatibleDependencies import java.net.URL plugins { @@ -59,9 +57,7 @@ configurations { all { exclude(module = "xpp3") exclude(group = "net.sf.saxon", module = "Saxon-HE") - forceGuava() - forceHapiVersion() - forceJacksonVersion() + removeIncompatibleDependencies() } } @@ -108,6 +104,13 @@ dependencies { testImplementation(Dependencies.mockitoKotlin) testImplementation(Dependencies.robolectric) testImplementation(Dependencies.truth) + + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + api(libName, constraints) + implementation(libName, constraints) + } + } } tasks.dokkaHtml.configure { diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 9e8718df2f..0e53d5f6ba 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -1,5 +1,3 @@ -import Dependencies.forceGuava - plugins { id(Plugins.BuildPlugins.application) id(Plugins.BuildPlugins.kotlinAndroid) @@ -40,8 +38,6 @@ android { kotlin { jvmToolchain(11) } } -configurations { all { forceGuava() } } - dependencies { androidTestImplementation(Dependencies.AndroidxTest.extJunit) androidTestImplementation(Dependencies.Espresso.espressoCore) diff --git a/engine/benchmark/build.gradle.kts b/engine/benchmark/build.gradle.kts index 42c50096c1..4ac37e5d05 100644 --- a/engine/benchmark/build.gradle.kts +++ b/engine/benchmark/build.gradle.kts @@ -1,8 +1,3 @@ -import Dependencies.forceGuava -import Dependencies.forceHapiVersion -import Dependencies.forceJacksonVersion -import Dependencies.removeIncompatibleDependencies - plugins { id(Plugins.BuildPlugins.androidLib) id(Plugins.BuildPlugins.kotlinAndroid) @@ -48,15 +43,6 @@ android { afterEvaluate { configureFirebaseTestLabForMicroBenchmark() } -configurations { - all { - removeIncompatibleDependencies() - forceGuava() - forceHapiVersion() - forceJacksonVersion() - } -} - dependencies { androidTestImplementation(Dependencies.Androidx.workRuntimeKtx) androidTestImplementation(Dependencies.AndroidxTest.benchmarkJunit) diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 3ae1cfb19d..ae44fe7a0c 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -1,6 +1,3 @@ -import Dependencies.forceGuava -import Dependencies.forceHapiVersion -import Dependencies.forceJacksonVersion import codegen.GenerateSearchParamsTask import java.net.URL @@ -87,11 +84,9 @@ configurations { exclude(module = "jakarta.activation-api") exclude(module = "javax.activation") exclude(module = "jakarta.xml.bind-api") + exclude(module = "hapi-fhir-caching-caffeine") + exclude(group = "com.github.ben-manes.caffeine", module = "caffeine") exclude(module = "jcl-over-slf4j") - - forceGuava() - forceHapiVersion() - forceJacksonVersion() } } @@ -152,6 +147,13 @@ dependencies { testImplementation(Dependencies.mockWebServer) testImplementation(Dependencies.robolectric) testImplementation(Dependencies.truth) + + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + api(libName, constraints) + implementation(libName, constraints) + } + } } tasks.dokkaHtml.configure { diff --git a/knowledge/build.gradle.kts b/knowledge/build.gradle.kts index b6a4f541f2..669b1031fb 100644 --- a/knowledge/build.gradle.kts +++ b/knowledge/build.gradle.kts @@ -1,4 +1,4 @@ -import Dependencies.forceGuava +import Dependencies.removeIncompatibleDependencies import java.net.URL plugins { @@ -67,13 +67,7 @@ android { afterEvaluate { configureFirebaseTestLabForLibraries() } -configurations { - all { - exclude(module = "xpp3") - exclude(module = "xpp3_min") - forceGuava() - } -} +configurations { all { removeIncompatibleDependencies() } } dependencies { androidTestImplementation(Dependencies.AndroidxTest.core) @@ -109,6 +103,12 @@ dependencies { testImplementation(Dependencies.mockWebServer) testImplementation(Dependencies.robolectric) testImplementation(Dependencies.truth) + + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + api(libName, constraints) + } + } } tasks.dokkaHtml.configure { diff --git a/workflow-testing/build.gradle.kts b/workflow-testing/build.gradle.kts index 9d0b9b5842..dadb083443 100644 --- a/workflow-testing/build.gradle.kts +++ b/workflow-testing/build.gradle.kts @@ -1,6 +1,3 @@ -import Dependencies.forceGuava -import Dependencies.forceHapiVersion -import Dependencies.forceJacksonVersion import Dependencies.removeIncompatibleDependencies plugins { @@ -15,14 +12,7 @@ android { kotlin { jvmToolchain(11) } } -configurations { - all { - removeIncompatibleDependencies() - forceGuava() - forceHapiVersion() - forceJacksonVersion() - } -} +configurations { all { removeIncompatibleDependencies() } } dependencies { compileOnly(Dependencies.Cql.evaluator) @@ -30,19 +20,15 @@ dependencies { compileOnly(Dependencies.Cql.evaluatorFhirUtilities) compileOnly(project(":engine")) { exclude(module = "truth") } - // Forces the most recent version of jackson, ignoring what dependencies use. - // Remove these lines when HAPI 6.4 becomes available. - compileOnly(Dependencies.Jackson.annotations) - compileOnly(Dependencies.Jackson.bom) - compileOnly(Dependencies.Jackson.core) - compileOnly(Dependencies.Jackson.databind) - compileOnly(Dependencies.Jackson.dataformatXml) - compileOnly(Dependencies.Jackson.jaxbAnnotations) - compileOnly(Dependencies.Jackson.jsr310) - compileOnly(Dependencies.junit) compileOnly(Dependencies.jsonAssert) compileOnly(Dependencies.woodstox) compileOnly(Dependencies.xmlUnit) compileOnly(Dependencies.truth) + + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + compileOnly(libName, constraints) + } + } } diff --git a/workflow/benchmark/build.gradle.kts b/workflow/benchmark/build.gradle.kts index 7cb5f95994..258054a8aa 100644 --- a/workflow/benchmark/build.gradle.kts +++ b/workflow/benchmark/build.gradle.kts @@ -1,4 +1,3 @@ -import Dependencies.forceGuava import Dependencies.removeIncompatibleDependencies plugins { @@ -46,12 +45,7 @@ android { afterEvaluate { configureFirebaseTestLabForMicroBenchmark() } -configurations { - all { - removeIncompatibleDependencies() - forceGuava() - } -} +configurations { all { removeIncompatibleDependencies() } } dependencies { androidTestImplementation(Dependencies.AndroidxTest.benchmarkJunit) @@ -74,4 +68,10 @@ dependencies { exclude(group = Dependencies.androidFhirGroup, module = Dependencies.androidFhirKnowledgeModule) } androidTestImplementation(project(":workflow-testing")) + + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + androidTestImplementation(libName, constraints) + } + } } diff --git a/workflow/build.gradle.kts b/workflow/build.gradle.kts index cc26ff7c98..27b9a66c88 100644 --- a/workflow/build.gradle.kts +++ b/workflow/build.gradle.kts @@ -1,6 +1,3 @@ -import Dependencies.forceGuava -import Dependencies.forceHapiVersion -import Dependencies.forceJacksonVersion import Dependencies.removeIncompatibleDependencies import java.net.URL @@ -76,14 +73,7 @@ android { afterEvaluate { configureFirebaseTestLabForLibraries() } -configurations { - all { - removeIncompatibleDependencies() - forceGuava() - forceHapiVersion() - forceJacksonVersion() - } -} +configurations { all { removeIncompatibleDependencies() } } dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) @@ -122,6 +112,13 @@ dependencies { testImplementation(Dependencies.xmlUnit) testImplementation(project(mapOf("path" to ":knowledge"))) testImplementation(project(":workflow-testing")) + + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + api(libName, constraints) + implementation(libName, constraints) + } + } } tasks.dokkaHtml.configure { From 3260a1ea7c4409fee1208cca290c567c9edf098b Mon Sep 17 00:00:00 2001 From: aditya-07 <adityakhajuria@google.com> Date: Tue, 9 Jan 2024 14:50:30 +0530 Subject: [PATCH 68/69] Cleanup the text during the re-bind of the Decimal and Integer view holders. (#2403) * Set the draft value and clear the text when answer values are not there * Review comments: Refactored code and tests --- .../EditTextDecimalViewHolderFactory.kt | 15 +++---- .../EditTextIntegerViewHolderFactory.kt | 12 +++--- .../EditTextDecimalViewHolderFactoryTest.kt | 40 ++++++++++++++++++- .../EditTextIntegerViewHolderFactoryTest.kt | 40 ++++++++++++++++++- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextDecimalViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextDecimalViewHolderFactory.kt index 31a5058c53..a4fb8d2443 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextDecimalViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextDecimalViewHolderFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,16 +47,17 @@ internal object EditTextDecimalViewHolderFactory : ) { val questionnaireItemViewItemDecimalAnswer = questionnaireViewItem.answers.singleOrNull()?.valueDecimalType?.value?.toString() - val draftAnswer = questionnaireViewItem.draftAnswer?.toString() - val decimalStringToDisplay = questionnaireItemViewItemDecimalAnswer ?: draftAnswer - - if ( - decimalStringToDisplay?.toDoubleOrNull() != + if (questionnaireItemViewItemDecimalAnswer.isNullOrEmpty() && draftAnswer.isNullOrEmpty()) { + textInputEditText.setText("") + } else if ( + questionnaireItemViewItemDecimalAnswer?.toDoubleOrNull() != textInputEditText.text.toString().toDoubleOrNull() ) { - textInputEditText.setText(decimalStringToDisplay) + textInputEditText.setText(questionnaireItemViewItemDecimalAnswer) + } else if (draftAnswer != null && draftAnswer != textInputEditText.text.toString()) { + textInputEditText.setText(draftAnswer) } // Update error message if draft answer present if (draftAnswer != null) { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextIntegerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextIntegerViewHolderFactory.kt index bb87204486..b9da83ca2b 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextIntegerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextIntegerViewHolderFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -64,14 +64,16 @@ internal object EditTextIntegerViewHolderFactory : questionnaireViewItem.answers.singleOrNull()?.valueIntegerType?.value?.toString() val draftAnswer = questionnaireViewItem.draftAnswer?.toString() - val text = answer ?: draftAnswer - // Update the text on the UI only if the value of the saved answer or draft answer // is different from what the user is typing. We compare the two fields as integers to // avoid shifting focus if the text values are different, but their integer representation // is the same (e.g. "001" compared to "1") - if ((text?.toIntOrNull() != textInputEditText.text.toString().toIntOrNull())) { - textInputEditText.setText(text) + if (answer.isNullOrEmpty() && draftAnswer.isNullOrEmpty()) { + textInputEditText.setText("") + } else if (answer?.toIntOrNull() != textInputEditText.text.toString().toIntOrNull()) { + textInputEditText.setText(answer) + } else if (draftAnswer != null && draftAnswer != textInputEditText.text.toString()) { + textInputEditText.setText(draftAnswer) } // Update error message if draft answer present diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextDecimalViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextDecimalViewHolderFactoryTest.kt index 8fcfa3f609..45aca98cc5 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextDecimalViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextDecimalViewHolderFactoryTest.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. @@ -447,4 +447,42 @@ class EditTextDecimalViewHolderFactoryTest { assertThat(viewHolder.itemView.findViewById<TextInputLayout>(R.id.text_input_layout).helperText) .isNull() } + + @Test + fun `bind again should remove previous text`() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent(), + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "1.1.1.1", + ), + ) + + assertThat( + viewHolder.itemView + .findViewById<TextInputEditText>(R.id.text_input_edit_text) + .text + .toString(), + ) + .isEqualTo("1.1.1.1") + + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent(), + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + assertThat( + viewHolder.itemView + .findViewById<TextInputEditText>(R.id.text_input_edit_text) + .text + .toString(), + ) + .isEqualTo("") + } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextIntegerViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextIntegerViewHolderFactoryTest.kt index a0a8bb9d76..4f3eb3a056 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextIntegerViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextIntegerViewHolderFactoryTest.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,4 +369,42 @@ class EditTextIntegerViewHolderFactoryTest { assertThat(viewHolder.itemView.findViewById<TextInputLayout>(R.id.text_input_layout).helperText) .isNull() } + + @Test + fun `bind again should remove previous text`() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent(), + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "9999999999", + ), + ) + + assertThat( + viewHolder.itemView + .findViewById<TextInputEditText>(R.id.text_input_edit_text) + .text + .toString(), + ) + .isEqualTo("9999999999") + + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent(), + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + assertThat( + viewHolder.itemView + .findViewById<TextInputEditText>(R.id.text_input_edit_text) + .text + .toString(), + ) + .isEqualTo("") + } } From 30a07d77597e298620b1efc0f745ebbf453f0242 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Tue, 9 Jan 2024 12:13:19 -0500 Subject: [PATCH 69/69] Add API call options to the FhirOperator for OpenSRP (#2383) * Offers additional options to evaluate measures and $apply in the FhirOperator * Adds suggestions by @MJ1998 * Adding the additional data as parameter for measure evaluate * Spotless * minor change to retrigger kokoro. * spotless --- .../android/fhir/db/impl/DatabaseImplTest.kt | 2 +- .../com/google/android/fhir/FhirEngine.kt | 2 +- .../com/google/android/fhir/db/Database.kt | 2 +- .../android/fhir/db/impl/DatabaseImpl.kt | 2 +- .../android/fhir/db/impl/dao/ResourceDao.kt | 2 +- .../google/android/fhir/search/MoreSearch.kt | 2 +- .../android/fhir/search/NestedSearch.kt | 2 +- .../search/NumberSearchParameterizedTest.kt | 2 +- .../google/android/fhir/search/SearchTest.kt | 2 +- .../fhir/workflow/testing/CqlBuilder.kt | 4 +- .../android/fhir/workflow/FhirOperator.kt | 132 +++++++++++++----- 11 files changed, 111 insertions(+), 43 deletions(-) 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 25efca3a2d..315791acf0 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt index a006ab2c29..0bec8f3e2b 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/main/java/com/google/android/fhir/db/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt index ddbd3a9401..83157a35ba 100644 --- a/engine/src/main/java/com/google/android/fhir/db/Database.kt +++ b/engine/src/main/java/com/google/android/fhir/db/Database.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index 59fe65a8e1..af044b4f63 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt index bae8f3f6b9..45786270bb 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt index 0075d6facf..d2b42c3f1a 100644 --- a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt b/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt index 1f63ed63bd..4818680ec5 100644 --- a/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/test/java/com/google/android/fhir/search/NumberSearchParameterizedTest.kt b/engine/src/test/java/com/google/android/fhir/search/NumberSearchParameterizedTest.kt index c00ff7dbc5..7dcb4e177d 100644 --- a/engine/src/test/java/com/google/android/fhir/search/NumberSearchParameterizedTest.kt +++ b/engine/src/test/java/com/google/android/fhir/search/NumberSearchParameterizedTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Google LLC + * Copyright 2021-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt b/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt index c9da480147..11f9c93a8f 100644 --- a/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt +++ b/engine/src/test/java/com/google/android/fhir/search/SearchTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt index 648d28a873..93085ec0e5 100644 --- a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt +++ b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,7 +40,7 @@ object CqlBuilder : Loadable() { /** * Compiles a CQL InputStream to ELM * - * @param cqlText the CQL Library + * @param cqlText the CQL Library's input stream * @return a [CqlTranslator] object that contains the elm representation of the library inside it. */ fun compile(cqlText: InputStream): CqlTranslator { diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt index 8c595e22b7..fb3e0a6c75 100644 --- a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt +++ b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.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. @@ -25,16 +25,20 @@ import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.knowledge.KnowledgeManager import com.google.android.fhir.workflow.repositories.FhirEngineRepository import com.google.android.fhir.workflow.repositories.KnowledgeRepository +import org.hl7.fhir.instance.model.api.IBaseBundle +import org.hl7.fhir.instance.model.api.IBaseDatatype import org.hl7.fhir.instance.model.api.IBaseParameters import org.hl7.fhir.instance.model.api.IBaseResource import org.hl7.fhir.r4.model.CanonicalType import org.hl7.fhir.r4.model.IdType -import org.hl7.fhir.r4.model.Measure import org.hl7.fhir.r4.model.MeasureReport import org.hl7.fhir.r4.model.Parameters +import org.hl7.fhir.r4.model.PlanDefinition +import org.hl7.fhir.r4.model.Reference import org.opencds.cqf.fhir.cql.EvaluationSettings import org.opencds.cqf.fhir.cql.LibraryEngine import org.opencds.cqf.fhir.cr.measure.MeasureEvaluationOptions +import org.opencds.cqf.fhir.cr.measure.common.MeasureReportType import org.opencds.cqf.fhir.cr.measure.r4.R4MeasureProcessor import org.opencds.cqf.fhir.cr.plandefinition.r4.PlanDefinitionProcessor import org.opencds.cqf.fhir.utility.monad.Eithers @@ -162,20 +166,35 @@ internal constructor( start: String, end: String, reportType: String, - subjectId: String?, - practitioner: String?, + subjectId: String? = null, + practitioner: String? = null, + additionalData: IBaseBundle? = null, ): MeasureReport { - val measure = Eithers.forLeft3<CanonicalType, IdType, Measure>(CanonicalType(measureUrl)) - return measureProcessor.evaluateMeasure( - /* measure = */ measure, - /* periodStart = */ start, - /* periodEnd = */ end, - /* reportType = */ reportType, - /* subjectIds = */ listOf( - subjectId, - ), // https://github.com/cqframework/clinical-reasoning/issues/358 - /* additionalData = */ null, - ) + val subject = + if (!practitioner.isNullOrBlank()) { + checkAndAddType(practitioner, "Practitioner") + } else if (!subjectId.isNullOrBlank()) { + checkAndAddType(subjectId, "Patient") + } else { + // List of null is required to run population-level measures + null + } + + val report = + measureProcessor.evaluateMeasure( + /* measure = */ Eithers.forLeft3(CanonicalType(measureUrl)), + /* periodStart = */ start, + /* periodEnd = */ end, + /* reportType = */ reportType, + /* subjectIds = */ listOf(subject), + /* additionalData = */ additionalData, + ) + + // add subject reference for non-individual reportTypes + if (report.type.name == MeasureReportType.SUMMARY.name && !subject.isNullOrBlank()) { + report.setSubject(Reference(subject)) + } + return report } /** @@ -193,11 +212,6 @@ internal constructor( return generateCarePlan(planDefinitionId, subject, encounterId = null) } - @WorkerThread - fun generateCarePlan(planDefinition: CanonicalType, subject: String): IBaseResource { - return generateCarePlan(planDefinition, subject, encounterId = null) - } - /** * Generates a [CarePlan] based on the provided inputs. * @@ -239,7 +253,18 @@ internal constructor( fun generateCarePlan( planDefinition: CanonicalType, subject: String, - encounterId: String?, + encounterId: String? = null, + practitionerId: String? = null, + organizationId: String? = null, + userType: IBaseDatatype? = null, + userLanguage: IBaseDatatype? = null, + userTaskContext: IBaseDatatype? = null, + setting: IBaseDatatype? = null, + settingContext: IBaseDatatype? = null, + parameters: IBaseParameters? = null, + useServerData: Boolean? = null, + bundle: IBaseBundle? = null, + prefetchData: IBaseParameters? = null, ): IBaseResource { return planDefinitionProcessor.apply( /* id = */ null, @@ -247,21 +272,64 @@ internal constructor( /* planDefinition = */ null, /* subject = */ subject, /* encounterId = */ encounterId, - /* practitionerId = */ null, - /* organizationId = */ null, - /* userType = */ null, - /* userLanguage = */ null, - /* userTaskContext = */ null, - /* setting = */ null, - /* settingContext = */ null, - /* parameters = */ null, - /* useServerData = */ null, - /* bundle = */ null, - /* prefetchData = */ null, + /* practitionerId = */ practitionerId, + /* organizationId = */ organizationId, + /* userType = */ userType, + /* userLanguage = */ userLanguage, + /* userTaskContext = */ userTaskContext, + /* setting = */ setting, + /* settingContext = */ settingContext, + /* parameters = */ parameters, + /* useServerData = */ useServerData, + /* bundle = */ bundle, + /* prefetchData = */ prefetchData, libraryProcessor, ) as IBaseResource } + @WorkerThread + fun generateCarePlan( + planDefinition: PlanDefinition, + subject: String, + encounterId: String? = null, + practitionerId: String? = null, + organizationId: String? = null, + userType: IBaseDatatype? = null, + userLanguage: IBaseDatatype? = null, + userTaskContext: IBaseDatatype? = null, + setting: IBaseDatatype? = null, + settingContext: IBaseDatatype? = null, + parameters: IBaseParameters? = null, + useServerData: Boolean? = null, + bundle: IBaseBundle? = null, + prefetchData: IBaseParameters? = null, + ): IBaseResource { + return planDefinitionProcessor.apply( + /* id = */ null, + /* canonical = */ null, + /* planDefinition = */ planDefinition, + /* subject = */ subject, + /* encounterId = */ encounterId, + /* practitionerId = */ practitionerId, + /* organizationId = */ organizationId, + /* userType = */ userType, + /* userLanguage = */ userLanguage, + /* userTaskContext = */ userTaskContext, + /* setting = */ setting, + /* settingContext = */ settingContext, + /* parameters = */ parameters, + /* useServerData = */ useServerData, + /* bundle = */ bundle, + /* prefetchData = */ prefetchData, + libraryProcessor, + ) as IBaseResource + } + + /** Checks if the Resource ID contains a type and if not, adds a default type */ + fun checkAndAddType(id: String, defaultType: String): String { + return if (id.indexOf("/") == -1) "$defaultType/$id" else id + } + class Builder(private val applicationContext: Context) { private var fhirContext: FhirContext? = null private var fhirEngine: FhirEngine? = null