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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d7a9b64d9..191fd8401c 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,49 +22,65 @@ 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 + +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. build: + runs-on: ubuntu-22.04-8core + timeout-minutes: 60 + permissions: + actions: read + contents: read - runs-on: ubuntu-22.04-64core + strategy: + fail-fast: false # 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 - with: - access_token: ${{ github.token }} # 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 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 - name: Upload maven repo - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: maven-repository path: build/ci-repo @@ -76,7 +92,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@v4 with: name: build path: build.zip diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 6b30b500f4..925aff49bc 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" ] @@ -9,10 +28,14 @@ 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 - runs-on: ubuntu-22.04-64core + runs-on: ubuntu-22.04-8core timeout-minutes: 60 permissions: actions: read @@ -26,16 +49,16 @@ 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 + uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} @@ -46,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}}" 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/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index b515c1f35d..4dec42c493 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,15 +9,15 @@ 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") + implementation("com.android.tools.build:gradle:8.1.4") - 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.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.12.0") + implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.10.0") + implementation("com.squareup:kotlinpoet:1.15.3") } diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index bf3b3fd7b7..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 { @@ -41,39 +42,54 @@ 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}" - 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 { @@ -83,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 { @@ -119,6 +136,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 { @@ -151,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}" @@ -163,6 +184,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}" @@ -218,24 +242,20 @@ 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" } 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" 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" @@ -253,6 +273,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" @@ -260,6 +281,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" @@ -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 Unit> { + return mutableMapOf 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/buildSrc/src/main/kotlin/LicenseeConfig.kt b/buildSrc/src/main/kotlin/LicenseeConfig.kt index ff66011e62..554c26a87a 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") { @@ -113,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("") } @@ -131,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("") } @@ -150,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") @@ -174,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/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index 3aadfdf83b..38cb8d700c 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" } @@ -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" } @@ -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" } 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/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.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/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/README.md b/codelabs/engine/README.md index fef62669b3..9b116e4cbb 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,355 @@ 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. +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 + } + ``` -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: + This static method lets you retrieve the FHIR Engine instance from anywhere + in the app using the context. -```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) }, - ), - ) -) -``` +## Sync data with FHIR server -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). +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")) -3. In `FhirApplication` class, add the following property to lazily - instantiate an actual FHIR Engine instance: + override suspend fun getNextRequest(): DownloadRequest? { + val url = urls.poll() ?: return null + return DownloadRequest.of(url) + } -```kotlin - private val fhirEngine: FhirEngine by lazy { FhirEngineProvider.getInstance(this) } -``` + override suspend fun getSummaryRequestUrls() = mapOf() -4. Finally, add the following code as a convenience method for the rest of the - codelab: + 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 + } + } + ``` -```kotlin -companion object { - fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine -} -``` + 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. -## Sync data with FHIR server +1. Create a new class `AppFhirSyncWorker.kt` This class defines how the app + will sync with the remote FHIR server using a background worker. -1. Create a new class `DownloadWorkManagerImpl.kt`: + ```kotlin + class AppFhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : + FhirSyncWorker(appContext, workerParams) { -```kotlin -class DownloadWorkManagerImpl : DownloadWorkManager { - private val urls = LinkedList(listOf("Patient")) + override fun getDownloadWorkManager() = DownloadWorkManagerImpl() - override suspend fun getNextRequest(): Request? { - val url = urls.poll() ?: return null - return Request.of(url) - } + override fun getConflictResolver() = AcceptLocalConflictResolver + + override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) + } + ``` + + Here, we've defined which download manager, conflict resolver, and FHIR + engine instance to use for syncing. - override suspend fun getSummaryRequestUrls() = mapOf() +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 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 } + ```kotlin + viewModelScope.launch { + 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 + AppFhirSyncWorker we defined earlier. It will then update the UI based on + the state of the sync process. + +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 -> {} } - return bundleCollection - } -} -``` + ``` -2. Create a new class `FhirSyncWorker.kt` + 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. -```kotlin -class FhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : - FhirSyncWorker(appContext, workerParams) { +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. - override fun getDownloadWorkManager() = DownloadWorkManagerImpl() +Patient list - override fun getConflictResolver() = AcceptLocalConflictResolver +## Modify and Upload Patient Data - override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) -} -``` +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`. -3. In `PatientListViewModel.kt`, add the following code to the body of - `triggerOneTimeSync()` function +### **Step 1**: Set Up the Modification Logic in PatientListViewModel -```kotlin -viewModelScope.launch { - Sync.oneTimeSync(getApplication()) - .shareIn(this, SharingStarted.Eagerly, 10) - .collect { _pollState.emit(it) } +The code in this section is added to the `triggerUpdate` function in +`PatientListViewModel` + +1. **Access the FHIR Engine**: + + Start by getting a reference to the FHIR engine in the + `PatientListViewModel.kt`. + + ```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) } -``` + ``` -4. In `PatientListFragment.kt`, add the following code to the body of function - `handleSyncJobStatus` + Similarly, update each patient in the `patientsFromTaunton` list to have + their city changed to `Wakefield`. -```kotlin -when (syncJobStatus) { - is SyncJobStatus.Finished -> { - Toast.makeText(requireContext(), "Sync Finished", Toast.LENGTH_SHORT).show() - viewModel.searchPatientsByName("") + ```kotlin + patientsFromTaunton.forEach { + it.resource.address.first().city = "Wakefield" + fhirEngine.update(it.resource) } - 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. +1. **Initiate Synchronization**: -Patient list + After modifying the data locally, trigger a one-time sync to ensure the data + is updated on the FHIR server. -## Modify and upload patient data + ```kotlin + triggerOneTimeSync() + } + ``` -In `PatientListViewModel.kt`, add the following code to `triggerUpdate` function + The closing brace `}` signifies the end of the coroutine launched at the + beginning. -```kotlin +### **Step 2**: Test the Functionality - viewModelScope.launch { - val fhirEngine = FhirApplication.fhirEngine(getApplication()) - - val patientsFromWakefield = - fhirEngine.search { - filter( - Patient.ADDRESS_CITY, - { - modifier = StringFilterModifier.CONTAINS - value = "Wakefield" - } - ) - } +1. **UI Testing**: - val patientsFromTaunton = - fhirEngine.search { - filter( - Patient.ADDRESS_CITY, - { - modifier = StringFilterModifier.CONTAINS - value = "Taunton" - } - ) - } + Run your app. Click the `Update` button in the menu. You should see the + address cities for patient `Aaron697` and `Abby752` swapped. - patientsFromWakefield.forEach { - it.address.first().city = "Taunton" - fhirEngine.update(it) - } +1. **Server Verification**: - patientsFromTaunton.forEach { - it.address.first().city = "Wakefield" - fhirEngine.update(it) - } + 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. - triggerOneTimeSync() - } -``` +By following these steps, you've successfully implemented a mechanism to modify +patient data and synchronize the changes with your FHIR server. -Now click the `Update` button in the menu, you should see the address city for -patient `Aaron697` and `Abby752` are swapped. +## Search for Patients by Name -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. +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. -## Search for patients by name +### **Step 1**: Update the Function Signature -1. In `PatientListViewModel.kt` change the signature of function - `getSearchResults()` to `getSearchResults(nameQuery: String = "")`. +Navigate to your `PatientListViewModel.kt` file and find the function named +`searchPatientsByName`. We will be adding code into this function. -2. Modify the function body and add the following code to the `search` - function call +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 -if (nameQuery.isNotEmpty()) { - filter( - Patient.NAME, - { - modifier = StringFilterModifier.CONTAINS - value = nameQuery + viewModelScope.launch { + 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 } + } } ``` -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 +492,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) - diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts index e31cb98661..b5ab70788a 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-beta05") + implementation("androidx.fragment:fragment-ktx:1.6.1") } 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 4150da3492..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 @@ -29,8 +29,10 @@ class PatientItemRecyclerViewAdapter : class PatientItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Patient, newItem: Patient) = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Patient, newItem: Patient) = - oldItem.equalsDeep(newItem) + override fun areContentsTheSame( + 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/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 0042ff52b7..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 @@ -41,15 +41,21 @@ class PatientListViewModel(application: Application) : AndroidViewModel(applicat 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 @@ -66,7 +72,7 @@ class PatientListViewModel(application: Application) : AndroidViewModel(applicat 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/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.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 diff --git a/codelabs/engine/gradle/wrapper/gradle-wrapper.jar b/codelabs/engine/gradle/wrapper/gradle-wrapper.jar index e708b1c023..ccebba7710 100644 Binary files a/codelabs/engine/gradle/wrapper/gradle-wrapper.jar and b/codelabs/engine/gradle/wrapper/gradle-wrapper.jar differ 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 diff --git a/codelabs/engine/gradlew b/codelabs/engine/gradlew index 4f906e0c81..79a61d421c 100755 --- a/codelabs/engine/gradlew +++ b/codelabs/engine/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - 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 diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 0d2c90c049..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.2-android") } - } -} +configurations { all { removeIncompatibleDependencies() } } 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(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 6e8dbee0a8..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() } } @@ -82,12 +78,11 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) - implementation(Dependencies.androidFhirCommon) implementation(Dependencies.Androidx.appCompat) 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") @@ -96,6 +91,7 @@ dependencies { implementation(Dependencies.Kotlin.kotlinCoroutinesCore) implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.Lifecycle.viewModelKtx) + implementation(Dependencies.androidFhirCommon) implementation(Dependencies.material) implementation(Dependencies.timber) @@ -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/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..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 @@ -478,7 +479,26 @@ class QuestionnaireUiEspressoTest { } } - private fun buildFragmentFromQuestionnaire(fileName: String, isReviewMode: Boolean = false) { + @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 + 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 +511,7 @@ class QuestionnaireUiEspressoTest { add(R.id.container_holder, questionnaireFragment) } } + return questionnaireFragment } private fun buildFragmentFromQuestionnaire( 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( 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 6dfb20a6bc..a6ced886c3 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 @@ -297,6 +297,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 79ef7478b2..75afd1149b 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 @@ -449,6 +449,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/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/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt index 9f7c29f838..cb5687e1f9 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 @@ -806,10 +806,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 List.zipByLinkId( questionnaireResponseItemList: List, @@ -819,12 +818,13 @@ internal inline fun List.zipByLink QuestionnaireResponse.QuestionnaireResponseItemComponent, ) -> T, ): List { - 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/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 dc4404b5da..52a8745a81 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 @@ -25,6 +25,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 @@ -81,6 +82,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) { items @@ -113,12 +128,11 @@ internal class ExpressionEvaluator( expression: Expression, ): List { val appContext = extractDependentVariables(expression, questionnaireItem) - return fhirPathEngine.evaluate( - appContext, + return evaluateToBase( questionnaireResponse, - null, questionnaireResponseItem, expression.expression, + appContext, ) } @@ -209,7 +223,7 @@ internal class ExpressionEvaluator( * @param variablesMap the [Map] of variables, the default value is empty map is * defined */ - internal fun extractDependentVariables( + private fun extractDependentVariables( expression: Expression, questionnaireItem: QuestionnaireItemComponent, variablesMap: MutableMap = mutableMapOf(), @@ -224,7 +238,10 @@ internal class ExpressionEvaluator( ) } } - return variablesMap + return variablesMap.apply { + put(questionnaireFhirPathSupplement, questionnaire) + put(questionnaireItemFhirPathSupplement, questionnaireItem) + } } /** @@ -282,7 +299,9 @@ internal class ExpressionEvaluator( val fhirPathsEvaluatedPairs = questionnaireLaunchContextMap + ?.toMutableMap() .takeIf { !it.isNullOrEmpty() } + ?.also { it.put(questionnaireFhirPathSupplement, questionnaire) } ?.let { evaluateXFhirEnhancement(expression, it) } ?: emptySequence() @@ -311,19 +330,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 @@ -432,13 +444,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) { @@ -447,5 +457,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> 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, 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, +) = + 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 = mapOf(), +): List { + 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 { + return fhirPathEngine.evaluate( + /* base = */ type, + /* path = */ expression, + ) +} + +/** Evaluates the given list of [Base] elements and returns boolean result */ +internal fun convertToBoolean(items: List) = 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 eeb37f4ca3..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 @@ -28,7 +28,8 @@ 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.fhirpath.fhirPathEngine +import com.google.android.fhir.datacapture.extensions.zipByLinkId +import com.google.android.fhir.datacapture.fhirpath.evaluateToBase import java.lang.reflect.Field import java.lang.reflect.Method import java.lang.reflect.ParameterizedType @@ -252,7 +253,13 @@ object ResourceMapper { questionnaireItem.initialExpression ?.let { - fhirPathEngine.evaluate(launchContexts, null, null, null, it.expression).firstOrNull() + evaluateToBase( + questionnaireResponse = null, + questionnaireResponseItem = null, + expression = it.expression, + contextMap = launchContexts, + ) + .firstOrNull() } ?.let { // Set initial value for the questionnaire item. Questionnaire items should not have both @@ -282,31 +289,17 @@ object ResourceMapper { extractionResult: MutableList, 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/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) } 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/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt index 7250b0ce62..e3d7dc2f9f 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 @@ -3874,6 +3875,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 // @@ -4583,7 +4681,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 { @@ -4634,6 +4732,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/extensions/MoreQuestionnaireItemComponentsTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt index 6b1c5bc11c..5e155c1e0b 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() + + 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/fhirpath/ExpressionEvaluatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt index 9e1d533384..5266b19fb7 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 = 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..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 @@ -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) @@ -1039,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" } @@ -1189,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 { 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(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(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(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(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(R.id.text_input_edit_text) + .text + .toString(), + ) + .isEqualTo("9999999999") + + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent(), + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + assertThat( + viewHolder.itemView + .findViewById(R.id.text_input_edit_text) + .text + .toString(), + ) + .isEqualTo("") + } } 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/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 get() = _lastSyncTimestampLiveData - private val _pollState = MutableSharedFlow() - val pollState: Flow + private val _pollState = MutableSharedFlow() + val pollState: Flow get() = _pollState + private val _pollPeriodicSyncJobStatus = MutableSharedFlow() + val pollPeriodicSyncJobStatus: Flow + get() = _pollPeriodicSyncJobStatus + init { viewModelScope.launch { Sync.periodicSync( @@ -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(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(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/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/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" /> 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 @@ 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 @@ @@ -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/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 @@ #fff8e1 #c62828 #ffebee - #10156125 + #DADCE0 #ffffff #E8F0FE #9AFFFFFF 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 @@ 20dp 2dp 5dp - 170dp + 170dp + 65dp 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 @@ 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/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/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..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 @@ -36,6 +37,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 +90,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")) : @@ -135,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/build.gradle.kts b/engine/build.gradle.kts index 155f481ddc..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,10 +84,9 @@ configurations { exclude(module = "jakarta.activation-api") exclude(module = "javax.activation") exclude(module = "jakarta.xml.bind-api") - - forceGuava() - forceHapiVersion() - forceJacksonVersion() + exclude(module = "hapi-fhir-caching-caffeine") + exclude(group = "com.github.ben-manes.caffeine", module = "caffeine") + exclude(module = "jcl-over-slf4j") } } @@ -145,11 +141,19 @@ dependencies { testImplementation(Dependencies.AndroidxTest.workTestingRuntimeKtx) testImplementation(Dependencies.Kotlin.kotlinCoroutinesTest) testImplementation(Dependencies.junit) + testImplementation(Dependencies.jsonAssert) testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.mockitoKotlin) 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/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 7ea0240a23..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. @@ -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 @@ -40,12 +41,14 @@ 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 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 @@ -80,6 +83,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 @@ -233,6 +237,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") @@ -535,12 +556,14 @@ class DatabaseImplTest { .first { it.resourceId == "remote-patient-3" } .let { UploadSyncResult.Success( - it.token, listOf( - Patient().apply { - id = it.resourceId - meta = remoteMeta - }, + ResourceUploadResponseMapping( + listOf(it), + Patient().apply { + id = it.resourceId + meta = remoteMeta + }, + ), ), ) } @@ -748,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() @@ -778,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 @@ -820,7 +843,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/${patient.id}") + assertThat(result.single().resource.id).isEqualTo("Patient/${patient.id}") } @Test @@ -873,7 +896,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/${patient.id}") + assertThat(result.single().resource.id).isEqualTo("Patient/${patient.id}") } @Test @@ -928,7 +951,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -985,7 +1008,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1043,7 +1066,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1101,7 +1124,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1159,7 +1182,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1216,7 +1239,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1274,7 +1297,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1332,7 +1355,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1390,7 +1413,7 @@ class DatabaseImplTest { .getQuery(), ) - assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") + assertThat(result.single().resource.id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @Test @@ -1445,7 +1468,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1497,7 +1520,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1548,7 +1571,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1598,7 +1621,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1648,7 +1671,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1698,7 +1721,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1748,7 +1771,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1798,7 +1821,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1848,7 +1871,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1898,7 +1921,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Patient/1") + assertThat(result.single().resource.id).isEqualTo("Patient/1") } @Test @@ -1955,7 +1978,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2019,7 +2042,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2083,7 +2106,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2147,7 +2170,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2211,7 +2234,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2275,7 +2298,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2339,7 +2362,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2403,7 +2426,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.single().id).isEqualTo("Observation/1") + assertThat(result.single().resource.id).isEqualTo("Observation/1") } @Test @@ -2420,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 @@ -2483,7 +2506,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.map { it.logicalId }).containsExactly("100").inOrder() + assertThat(result.map { it.resource.logicalId }).containsExactly("100").inOrder() } @Test @@ -2529,7 +2552,7 @@ class DatabaseImplTest { } .getQuery(), ) - assertThat(result.map { it.logicalId }).containsExactly("100").inOrder() + assertThat(result.map { it.resource.logicalId }).containsExactly("100").inOrder() } @Test @@ -2555,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") } @@ -2583,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") } @@ -2672,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() } @@ -2747,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() } @@ -2839,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() } @@ -2903,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 @@ -2964,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 @@ -2989,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() } @@ -3079,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 @@ -3169,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 @@ -3488,50 +3513,576 @@ 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), ), - ), ), ) } + @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<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) + } + private companion object { const val mockEpochTimeStamp = 1628516301000 const val TEST_PATIENT_1_ID = "test_patient_1" @@ -3553,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/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<Long, MutableList<String>> + + 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/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt b/engine/src/androidTest/java/com/google/android/fhir/sync/SyncInstrumentedTest.kt index bb4f48b8c8..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. @@ -25,8 +25,10 @@ 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.TestFailingDatasource import com.google.android.fhir.testing.TestFhirEngineImpl import com.google.common.truth.Truth.assertThat import java.util.concurrent.TimeUnit @@ -35,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 @@ -56,6 +61,13 @@ class SyncInstrumentedTest { override fun getDownloadWorkManager(): DownloadWorkManager = TestDownloadManagerImpl() override fun getConflictResolver() = AcceptRemoteConflictResolver + + override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut + } + + class TestSyncWorkerForDownloadFailing(appContext: Context, workerParams: WorkerParameters) : + TestSyncWorker(appContext, workerParams) { + override fun getDataSource(): DataSource = TestFailingDatasource } @Test @@ -65,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) } @@ -75,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) @@ -91,7 +198,7 @@ class SyncInstrumentedTest { ) .transformWhile { emit(it) - it !is SyncJobStatus.Finished + it.currentSyncJobStatus !is CurrentSyncJobStatus.Succeeded } .shareIn(this, SharingStarted.Eagerly, 5) } @@ -107,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/FhirEngine.kt b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt index cc0a971721..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. @@ -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/FhirEngineProvider.kt b/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt index 6498d8feb8..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,8 +19,10 @@ 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 import org.hl7.fhir.r4.model.SearchParameter /** The provider for [FhirEngine] instance. */ @@ -31,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) { @@ -58,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) { @@ -161,4 +170,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/FhirServices.kt b/engine/src/main/java/com/google/android/fhir/FhirServices.kt index 75203b31ed..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. @@ -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 @@ -28,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 @@ -38,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 @@ -70,12 +73,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)), ) @@ -95,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/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 <R : Resource> getResourceClass(resourceType: String): Class<R> { return Class.forName(R4_RESOURCE_PACKAGE_PREFIX + className) as Class<R> } -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/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt index cbbc840b64..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. @@ -18,11 +18,13 @@ 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 import java.time.Instant +import java.util.UUID import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -93,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 @@ -105,6 +109,12 @@ internal interface Database { */ suspend fun getAllLocalChanges(): List<LocalChange> + /** + * Retrieves all [LocalChange]s for the [Resource] which has the [LocalChange] with the oldest + * [LocalChange.timestamp] + */ + suspend fun getAllChangesForEarliestChangedResource(): List<LocalChange> + /** Retrieves the count of [LocalChange]s stored in the database. */ suspend fun getLocalChangesCount(): Int @@ -114,6 +124,18 @@ internal interface Database { /** Remove the [LocalChangeEntity] s with matching resource ids. */ suspend fun deleteUpdates(resources: List<Resource>) + /** + * 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) @@ -139,6 +161,18 @@ internal interface Database { */ suspend fun getLocalChanges(type: ResourceType, id: String): List<LocalChange> + /** + * 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<LocalChange> + /** * Purge resource from database based on resource type and id without any deletion of data from * the server. @@ -152,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/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 a5cf77719c..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. @@ -22,18 +22,22 @@ 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 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 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 +49,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 +107,7 @@ internal class DatabaseImpl( MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + MIGRATION_7_8, ) } .build() @@ -114,7 +120,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 <R : Resource> insert(vararg resource: R): List<String> { val logicalIds = mutableListOf<String>() @@ -140,7 +150,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) } } @@ -191,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, ) } @@ -228,6 +258,10 @@ internal class DatabaseImpl( return db.withTransaction { localChangeDao.getLocalChangesCount() } } + override suspend fun getAllChangesForEarliestChangedResource(): List<LocalChange> { + return localChangeDao.getAllChangesForEarliestChangedResource().map { it.toLocalChange() } + } + override suspend fun deleteUpdates(token: LocalChangeToken) { db.withTransaction { localChangeDao.discardLocalChanges(token) } } @@ -247,6 +281,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<UUID>, + 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() } @@ -263,6 +376,12 @@ internal class DatabaseImpl( } } + override suspend fun getLocalChanges(resourceUuid: UUID): List<LocalChange> { + 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<String> { + val iterator: Iterator<*> = jsonObject.keys() + var key: String? + val referenceValues = mutableListOf<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 (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<String> { + val referenceValues = mutableListOf<String>() + 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<String>() + 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 ecd24b4ece..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<LocalChangeResourceReferenceEntity>, + ) { + 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<ResourceReferenceInfo> { + 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<ResourceReferenceInfo>.minus(set: Set<ResourceReferenceInfo>) = + 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<LocalChangeEntity> @Query( """ - SELECT COUNT(*) + SELECT * FROM LocalChangeEntity + WHERE LocalChangeEntity.id IN (:ids) + ORDER BY timestamp ASC""", + ) + abstract suspend fun getLocalChanges(ids: List<Long>): List<LocalChangeEntity> + + @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( @@ -206,7 +314,180 @@ internal abstract class LocalChangeDao { resourceId: String, ): List<LocalChangeEntity> + @Query( + """ + SELECT * + FROM LocalChangeEntity + WHERE resourceUuid = :resourceUuid + ORDER BY timestamp ASC + """, + ) + abstract suspend fun getLocalChanges( + resourceUuid: UUID, + ): List<LocalChangeEntity> + + @Query( + """ + SELECT * + FROM LocalChangeResourceReferenceEntity + WHERE resourceReferenceValue = :resourceReferenceValue + """, + ) + abstract suspend fun getLocalChangeReferencesWithValue( + resourceReferenceValue: String, + ): List<LocalChangeResourceReferenceEntity> + + @Query( + """ + SELECT * + FROM LocalChangeResourceReferenceEntity + WHERE localChangeId = :localChangeId + """, + ) + abstract suspend fun getReferencesForLocalChange( + localChangeId: Long, + ): List<LocalChangeResourceReferenceEntity> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertLocalChangeResourceReferences( + resourceReferences: List<LocalChangeResourceReferenceEntity>, + ) + + /** + * 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<UUID> { + 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<UUID> { + 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 + FROM LocalChangeEntity + ORDER BY timestamp ASC + LIMIT 1) + ORDER BY timestamp ASC + """, + ) + abstract suspend fun getAllChangesForEarliestChangedResource(): List<LocalChangeEntity> + 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 d7f25d388f..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. @@ -58,43 +58,87 @@ 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, + ) + } + + 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, + ) + } + + /** + * 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) } - open suspend fun insertAllRemote(resources: List<Resource>): List<UUID> { + 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<Resource>): List<UUID> { return resources.map { resource -> insertRemoteResource(resource) } } @@ -171,12 +215,29 @@ internal abstract class ResourceDao { resourceType: ResourceType, ): ResourceEntity? - @RawQuery abstract suspend fun getResources(query: SupportSQLiteQuery): List<String> + @Query( + """ + SELECT * + FROM ResourceEntity + WHERE resourceUuid = :resourceUuid + """, + ) + abstract suspend fun getResourceEntity( + resourceUuid: UUID, + ): ResourceEntity? + + @RawQuery + abstract suspend fun getResources(query: SupportSQLiteQuery): List<SerializedResourceWithUuid> + + @RawQuery + abstract suspend fun getForwardReferencedResources( + query: SupportSQLiteQuery, + ): List<ForwardIncludeSearchResponse> @RawQuery - abstract suspend fun getReferencedResources( + abstract suspend fun getReverseReferencedResources( query: SupportSQLiteQuery, - ): List<IndexedIdAndSerializedResource> + ): List<ReverseIncludeSearchResponse> @RawQuery abstract suspend fun countResources(query: SupportSQLiteQuery): Long @@ -189,7 +250,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) @@ -356,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/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/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/search/MoreSearch.kt b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt index 60b3fe1472..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. @@ -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>() @@ -293,6 +322,7 @@ internal fun Search.getQuery( filterArgs.addAll(it.args) } val whereArgs = mutableListOf<Any>() + val nestedArgs = mutableListOf<Any>() val query = when { isCount -> { @@ -311,11 +341,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 @@ -329,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 = ? @@ -342,7 +373,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/main/java/com/google/android/fhir/search/NestedSearch.kt b/engine/src/main/java/com/google/android/fhir/search/NestedSearch.kt index fe027fc272..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. @@ -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/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 fe5a78206b..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. @@ -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()) @@ -67,21 +72,41 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter val synchronizer = 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(), + ), + 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)) + } } } } @@ -92,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") /** @@ -102,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 acac045a14..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 @@ -44,20 +42,26 @@ 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 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 { @@ -66,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) } @@ -75,7 +79,7 @@ internal class FhirSynchronizer( } suspend fun synchronize(): SyncJobStatus { - setSyncState(SyncJobStatus.Started) + setSyncState(SyncJobStatus.Started()) return listOf(download(), upload()) .filterIsInstance<SyncResult.Error>() @@ -91,9 +95,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 +123,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/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/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/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<Int>() + + suspend fun initTotalCount() { + total = database.getLocalChangesCount() + } + + override suspend fun hasNext(): Boolean = database.getLocalChangesCount().isNotZero() + + override suspend fun next(): List<LocalChange> = + 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/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..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,11 +16,11 @@ package com.google.android.fhir.sync.upload +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. @@ -44,11 +44,17 @@ 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.uploadResponses.flatMap { + it.localChanges.flatMap { localChange -> localChange.token.ids } + }, + ), + ) + uploadSyncResult.uploadResponses.forEach { when (it) { - is Bundle -> updateVersionIdAndLastUpdated(it) - else -> updateVersionIdAndLastUpdated(it) + is BundleComponentUploadResponseMapping -> updateVersionIdAndLastUpdated(it.output) + is ResourceUploadResponseMapping -> updateVersionIdAndLastUpdated(it.output) } } } @@ -59,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) -> @@ -89,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 b5a8ee66d8..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(token, 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 localChangeToken: LocalChangeToken, - val resources: 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/FhirEngineProviderTest.kt b/engine/src/test/java/com/google/android/fhir/FhirEngineProviderTest.kt index aba268fde2..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. @@ -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 @@ -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()) @@ -95,4 +103,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") + } + } } 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 a77a6b76f4..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 @@ -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 @@ -31,12 +32,15 @@ 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 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 @@ -323,8 +327,12 @@ class FhirEngineImplTest { .syncUpload(LocalChangesFetchMode.AllChanges) { localChanges.addAll(it) UploadSyncResult.Success( - LocalChangeToken(it.flatMap { it.token.ids }), - listOf(), + listOf( + ResourceUploadResponseMapping( + it, + TEST_PATIENT_1, + ), + ), ) } .collect { emittedProgress.add(it) } @@ -592,6 +600,87 @@ class FhirEngineImplTest { assertResourceEquals(fhirEngine.get<Patient>("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" } 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..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. @@ -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 420e5e7da8..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. @@ -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,13 +1827,13 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? 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 +1850,7 @@ class SearchTest { assertThat(query.args) .isEqualTo( listOf( + ResourceType.Patient.name, ResourceType.Patient.name, ResourceType.Condition.name, Condition.SUBJECT.paramName, @@ -1896,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 ( @@ -1906,7 +1913,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 +1938,7 @@ class SearchTest { ResourceType.Patient.name, Patient.ADDRESS_COUNTRY.paramName, "IN", + ResourceType.Patient.name, ResourceType.Immunization.name, Immunization.PATIENT.paramName, ResourceType.Immunization.name, @@ -1968,13 +1976,13 @@ class SearchTest { assertThat(query.query) .isEqualTo( """ - SELECT a.serializedResource + SELECT a.resourceUuid, a.serializedResource FROM ResourceEntity a WHERE a.resourceType = ? 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 +1994,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 +2011,7 @@ class SearchTest { assertThat(query.args) .isEqualTo( listOf( + ResourceType.Patient.name, ResourceType.Patient.name, ResourceType.Condition.name, Condition.SUBJECT.paramName, @@ -2010,6 +2019,7 @@ class SearchTest { Condition.CODE.paramName, "44054006", "http://snomed.info/sct", + ResourceType.Patient.name, ResourceType.Condition.name, Condition.SUBJECT.paramName, ResourceType.Condition.name, @@ -2028,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 = ? @@ -2049,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 = ? @@ -2085,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 ( @@ -2126,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 ( @@ -2159,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 ( @@ -2190,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 ( @@ -2228,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 ( @@ -2251,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 + } + }, + "", + ) } } 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 11e7923cc4..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. @@ -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 @@ -129,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() } @@ -142,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 ebfb432d46..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 @@ -29,6 +28,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 @@ -49,6 +49,8 @@ class FhirSynchronizerTest { @Mock private lateinit var conflictResolver: ConflictResolver + @Mock private lateinit var fhirDataStore: FhirDataStore + private lateinit var fhirSynchronizer: FhirSynchronizer @Before @@ -56,11 +58,10 @@ class FhirSynchronizerTest { MockitoAnnotations.openMocks(this) fhirSynchronizer = FhirSynchronizer( - ApplicationProvider.getApplicationContext(), TestFhirEngineImpl, - uploader, - downloader, - conflictResolver, + UploadConfiguration(uploader), + DownloadConfiguration(downloader, conflictResolver), + fhirDataStore, ) } @@ -71,7 +72,6 @@ class FhirSynchronizerTest { `when`(uploader.upload(any())) .thenReturn( UploadSyncResult.Success( - LocalChangeToken(listOf()), listOf(), ), ) @@ -81,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 @@ -101,7 +101,6 @@ class FhirSynchronizerTest { `when`(uploader.upload(any())) .thenReturn( UploadSyncResult.Success( - LocalChangeToken(listOf()), listOf(), ), ) @@ -111,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) } @@ -136,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 9f81d9c60c..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. @@ -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 @@ -51,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)) @@ -59,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. @@ -75,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) @@ -91,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) 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..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(resources).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/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() } + } + } +} 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000000..ac89294412 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,11 @@ +# see https://docs.gradle.org/current/userguide/platforms.html + +[versions] +glide = "4.16.0" + +[libraries] +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } + +[bundles] + +[plugins] 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/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<ResourceMetadataEntity> + @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/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/ diff --git a/settings.gradle.kts b/settings.gradle.kts index a3fb255a35..16f5bbdf50 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") } @@ -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.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") + 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.16.0") } } @@ -49,6 +49,8 @@ include(":contrib:barcode") include(":datacapture") +include(":document") + include(":demo") include(":engine") 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-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-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/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 5ae54c8cb0..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,17 +73,9 @@ android { afterEvaluate { configureFirebaseTestLabForLibraries() } -configurations { - all { - removeIncompatibleDependencies() - forceGuava() - forceHapiVersion() - forceJacksonVersion() - } -} +configurations { all { removeIncompatibleDependencies() } } dependencies { - testImplementation(project(mapOf("path" to ":knowledge"))) coreLibraryDesugaring(Dependencies.desugarJdkLibs) androidTestImplementation(Dependencies.AndroidxTest.core) @@ -104,19 +93,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,7 +110,15 @@ dependencies { testImplementation(Dependencies.robolectric) testImplementation(Dependencies.truth) 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 { 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..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 } /** @@ -185,8 +204,12 @@ 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) + @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) } /** @@ -196,16 +219,20 @@ 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, - 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, @@ -222,6 +249,87 @@ internal constructor( ) as IBaseResource } + @WorkerThread + fun generateCarePlan( + planDefinition: CanonicalType, + 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 = */ planDefinition, + /* planDefinition = */ null, + /* 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 + } + + @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 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..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,8 +99,11 @@ class FhirOperatorTest { assertThat( fhirOperator.generateCarePlan( - planDefinitionId = "plandefinition-RuleFilters-1.0.0", - patientId = "Reportable", + 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", - patientId = "Patient/Patient-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,8 +139,8 @@ class FhirOperatorTest { val carePlan = fhirOperator.generateCarePlan( - planDefinitionId = "Plan-Definition-Example", - patientId = "Patient/Female-Patient-Example", + planDefinition = CanonicalType("http://example.com/PlanDefinition/Plan-Definition-Example"), + subject = "Patient/Female-Patient-Example", ) println(jsonParser.setPrettyPrint(true).encodeResourceToString(carePlan)) @@ -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)) } }