From 8878362b1dfd97ac60258a403ae1d7c6ee6f55cc Mon Sep 17 00:00:00 2001 From: aditya-07 Date: Thu, 13 Jun 2024 16:57:02 +0530 Subject: [PATCH 01/10] Ordering of patches with cycles during upload (#2524) * Ordering of patches with cyclic during upload * Ignored failing test * Refactored and added tests * Refactored * Updated code to use strongly connected resources * Review comments: Seperated the code for finding ssc. Added some tets for Bundle generation. * Refactored code and added tests. * Refactored code and added tests. * Updated StronglyConnectedPatches to compute node size * Added comments * Updated tarjan * Review comments: Updated docs --- .../fhir/sync/upload/patch/PatchGenerator.kt | 17 +- .../fhir/sync/upload/patch/PatchOrdering.kt | 57 +++--- .../upload/patch/PerChangePatchGenerator.kt | 34 ++-- .../upload/patch/PerResourcePatchGenerator.kt | 8 +- .../upload/patch/StronglyConnectedPatches.kt | 96 +++++++++ .../request/TransactionBundleGenerator.kt | 35 +++- .../upload/request/UploadRequestGenerator.kt | 9 +- .../upload/request/UrlRequestGenerator.kt | 32 ++- .../sync/upload/patch/PatchOrderingTest.kt | 127 +++++++----- .../patch/PerResourcePatchGeneratorTest.kt | 32 ++- .../patch/StronglyConnectedPatchesTest.kt | 104 ++++++++++ .../upload/request/IndividualGeneratorTest.kt | 22 +- .../request/TransactionBundleGeneratorTest.kt | 188 ++++++++++++++++-- 13 files changed, 615 insertions(+), 146 deletions(-) create mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/patch/StronglyConnectedPatches.kt create mode 100644 engine/src/test/java/com/google/android/fhir/sync/upload/patch/StronglyConnectedPatchesTest.kt 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 1a52da87c8..6dfda6e191 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 @@ -20,8 +20,8 @@ import com.google.android.fhir.LocalChange import com.google.android.fhir.db.Database /** - * Generates [Patch]es from [LocalChange]s and output [List<[PatchMapping]>] to keep a mapping of - * the [LocalChange]s to their corresponding generated [Patch] + * Generates [Patch]es from [LocalChange]s and output [List<[StronglyConnectedPatchMappings]>] 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. @@ -35,7 +35,7 @@ internal interface PatchGenerator { * NOTE: different implementations may have requirements on the size of [localChanges] and output * certain numbers of [Patch]es. */ - suspend fun generate(localChanges: List): List + suspend fun generate(localChanges: List): List } internal object PatchGeneratorFactory { @@ -67,3 +67,14 @@ internal data class PatchMapping( val localChanges: List, val generatedPatch: Patch, ) + +/** + * Structure to describe the cyclic nature of [PatchMapping]. + * - A single value in [patchMappings] signifies the acyclic nature of the node. + * - Multiple values in [patchMappings] signifies the cyclic nature of the nodes among themselves. + * + * [StronglyConnectedPatchMappings] is used by the engine to make sure that related resources get + * uploaded to the server in the same request to maintain the referential integrity of resources + * during creation. + */ +internal data class StronglyConnectedPatchMappings(val patchMappings: List) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PatchOrdering.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PatchOrdering.kt index c76fb46751..d0bfb5f364 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PatchOrdering.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/PatchOrdering.kt @@ -20,7 +20,26 @@ import androidx.annotation.VisibleForTesting import com.google.android.fhir.db.Database import com.google.android.fhir.db.LocalChangeResourceReference -private typealias Node = String +/** Represents a resource e.g. 'Patient/123' , 'Encounter/123'. */ +internal typealias Node = String + +/** + * Represents a collection of resources with reference to other resource represented as an edge. + * e.g. Two Patient resources p1 and p2, each with an encounter and subsequent observation will be + * represented as follows + * + * ``` + * [ + * 'Patient/p1' : [], + * 'Patient/p2' : [], + * 'Encounter/e1' : ['Patient/p1'], // Encounter.subject + * 'Encounter/e2' : ['Patient/p2'], // Encounter.subject + * 'Observation/o1' : ['Patient/p1', 'Encounter/e1'], // Observation.subject, Observation.encounter + * 'Observation/o2' : ['Patient/p2', 'Encounter/e2'], // Observation.subject, Observation.encounter + * ] + * ``` + */ +internal typealias Graph = Map> /** * Orders the [PatchMapping]s to maintain referential integrity during upload. @@ -53,15 +72,16 @@ internal object PatchOrdering { * {D} (UPDATE), then B,C needs to go before the resource A so that referential integrity is * retained. Order of D shouldn't matter for the purpose of referential integrity. * - * @return - A ordered list of the [PatchMapping]s based on the references to other [PatchMapping] - * if the mappings are acyclic - * - throws [IllegalStateException] otherwise + * @return A ordered list of the [StronglyConnectedPatchMappings] containing: + * - [StronglyConnectedPatchMappings] with single value for the [PatchMapping] based on the + * references to other [PatchMapping] if the mappings are acyclic + * - [StronglyConnectedPatchMappings] with multiple values for [PatchMapping]s based on the + * references to other [PatchMapping]s if the mappings are cyclic. */ - suspend fun List.orderByReferences( + suspend fun List.sccOrderByReferences( database: Database, - ): List { + ): List { val resourceIdToPatchMapping = associateBy { patchMapping -> patchMapping.resourceTypeAndId } - /* Get LocalChangeResourceReferences for all the local changes. A single LocalChange may have multiple LocalChangeResourceReference, one for each resource reference in the LocalChange.payload.*/ @@ -71,7 +91,10 @@ internal object PatchOrdering { .groupBy { it.localChangeId } val adjacencyList = createAdjacencyListForCreateReferences(localChangeIdToResourceReferenceMap) - return createTopologicalOrderedList(adjacencyList).mapNotNull { resourceIdToPatchMapping[it] } + + return StronglyConnectedPatches.scc(adjacencyList).map { + StronglyConnectedPatchMappings(it.mapNotNull { resourceIdToPatchMapping[it] }) + } } /** @@ -121,22 +144,4 @@ internal object PatchOrdering { } return references } - - private fun createTopologicalOrderedList(adjacencyList: Map>): List { - val stack = ArrayDeque() - val visited = mutableSetOf() - val currentPath = mutableSetOf() - - fun dfs(key: String) { - check(currentPath.add(key)) { "Detected a cycle." } - if (visited.add(key)) { - adjacencyList[key]?.forEach { dfs(it) } - stack.addFirst(key) - } - currentPath.remove(key) - } - - adjacencyList.keys.forEach { dfs(it) } - return stack.reversed() - } } 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 8c787f644e..c208e8c6ea 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,19 +25,23 @@ import com.google.android.fhir.LocalChange * maintain an audit trail. */ internal object PerChangePatchGenerator : PatchGenerator { - override suspend fun generate(localChanges: List): List = - localChanges.map { - 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, - ), - ) - } + override suspend fun generate( + localChanges: List, + ): List = + localChanges + .map { + 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, + ), + ) + } + .map { StronglyConnectedPatchMappings(listOf(it)) } } 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 0eaa944008..c524b32302 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 @@ -23,7 +23,7 @@ import com.github.fge.jsonpatch.JsonPatch import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChange.Type import com.google.android.fhir.db.Database -import com.google.android.fhir.sync.upload.patch.PatchOrdering.orderByReferences +import com.google.android.fhir.sync.upload.patch.PatchOrdering.sccOrderByReferences /** * Generates a [Patch] for all [LocalChange]es made to a single FHIR resource. @@ -35,8 +35,10 @@ import com.google.android.fhir.sync.upload.patch.PatchOrdering.orderByReferences internal class PerResourcePatchGenerator private constructor(val database: Database) : PatchGenerator { - override suspend fun generate(localChanges: List): List { - return generateSquashedChangesMapping(localChanges).orderByReferences(database) + override suspend fun generate( + localChanges: List, + ): List { + return generateSquashedChangesMapping(localChanges).sccOrderByReferences(database) } @androidx.annotation.VisibleForTesting diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/patch/StronglyConnectedPatches.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/StronglyConnectedPatches.kt new file mode 100644 index 0000000000..216229492b --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/patch/StronglyConnectedPatches.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.sync.upload.patch + +import kotlin.math.min + +internal object StronglyConnectedPatches { + + /** + * Takes a [directedGraph] and computes all the strongly connected components in the graph. + * + * @return An ordered List of strongly connected components of the [directedGraph]. The SCCs are + * topologically ordered which may change based on the ordering algorithm and the [Node]s inside + * a SSC may be ordered randomly depending on the path taken by algorithm to discover the nodes. + */ + fun scc(directedGraph: Graph): List> { + return findSCCWithTarjan(directedGraph) + } + + /** + * Finds strongly connected components in topological order. See + * https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm. + */ + private fun findSCCWithTarjan(diGraph: Graph): List> { + // Ideally the graph.keys should have all the nodes in the graph. But use values as well in case + // the input graph looks something like [ N1: [N2] ]. + val nodeToIndex = + (diGraph.keys + diGraph.values.flatten().toSet()) + .mapIndexed { index, s -> s to index } + .toMap() + + val sccs = mutableListOf>() + val lowLinks = IntArray(nodeToIndex.size) + var exploringCounter = 0 + val discoveryTimes = IntArray(nodeToIndex.size) + + val visitedNodes = BooleanArray(nodeToIndex.size) + val nodesCurrentlyInStack = BooleanArray(nodeToIndex.size) + val stack = ArrayDeque() + + fun Node.index() = nodeToIndex[this]!! + + fun dfs(at: Node) { + lowLinks[at.index()] = exploringCounter + discoveryTimes[at.index()] = exploringCounter + visitedNodes[at.index()] = true + exploringCounter++ + stack.addFirst(at) + nodesCurrentlyInStack[at.index()] = true + + diGraph[at]?.forEach { + if (!visitedNodes[it.index()]) { + dfs(it) + } + + if (nodesCurrentlyInStack[it.index()]) { + lowLinks[at.index()] = min(lowLinks[at.index()], lowLinks[it.index()]) + } + } + + // We have found the head node in the scc. + if (lowLinks[at.index()] == discoveryTimes[at.index()]) { + val connected = mutableListOf() + var node: Node + do { + node = stack.removeFirst() + connected.add(node) + nodesCurrentlyInStack[node.index()] = false + } while (node != at && stack.isNotEmpty()) + sccs.add(connected.reversed()) + } + } + + diGraph.keys.forEach { + if (!visitedNodes[it.index()]) { + dfs(it) + } + } + + return sccs + } +} 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 fa4fc5bc31..d622eee5d9 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package com.google.android.fhir.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 com.google.android.fhir.sync.upload.patch.StronglyConnectedPatchMappings import org.hl7.fhir.r4.model.Bundle /** Generates list of [BundleUploadRequest] of type Transaction [Bundle] from the [Patch]es */ @@ -29,10 +30,38 @@ internal class TransactionBundleGenerator( (patch: Patch, useETagForUpload: Boolean) -> BundleEntryComponentGenerator, ) : UploadRequestGenerator { + /** + * In order to accommodate cyclic dependencies between [PatchMapping]s and maintain referential + * integrity on the server, the [PatchMapping]s in a [StronglyConnectedPatchMappings] are all put + * in a single [BundleUploadRequestMapping]. Based on the [generatedBundleSize], the remaining + * space of the [BundleUploadRequestMapping] maybe filled with other + * [StronglyConnectedPatchMappings] mappings. + * + * In case a single [StronglyConnectedPatchMappings] has more [PatchMapping]s than the + * [generatedBundleSize], [generatedBundleSize] will be ignored so that all of the dependent + * mappings in [StronglyConnectedPatchMappings] can be sent in a single [Bundle]. + */ override fun generateUploadRequests( - mappedPatches: List, + mappedPatches: List, ): List { - return mappedPatches.chunked(generatedBundleSize).map { patchList -> + val mappingsPerBundle = mutableListOf>() + + var bundle = mutableListOf() + mappedPatches.forEach { + if ((bundle.size + it.patchMappings.size) <= generatedBundleSize) { + bundle.addAll(it.patchMappings) + } else { + if (bundle.isNotEmpty()) { + mappingsPerBundle.add(bundle) + bundle = mutableListOf() + } + bundle.addAll(it.patchMappings) + } + } + + if (bundle.isNotEmpty()) mappingsPerBundle.add(bundle) + + return mappingsPerBundle.map { patchList -> generateBundleRequest(patchList).let { mappedBundleRequest -> BundleUploadRequestMapping( splitLocalChanges = mappedBundleRequest.first, 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 51ae7711ff..86af7a4b64 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 @@ -19,19 +19,20 @@ 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 com.google.android.fhir.sync.upload.patch.StronglyConnectedPatchMappings import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.codesystems.HttpVerb /** * 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. + * [List<[StronglyConnectedPatchMappings]>]. 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 [UploadRequestMapping] from the [PatchMapping]s */ fun generateUploadRequests( - mappedPatches: List, + mappedPatches: List, ): List } 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 5fbb174e8a..8e576760c3 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 @@ -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. @@ -21,6 +21,7 @@ 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 com.google.android.fhir.sync.upload.patch.StronglyConnectedPatchMappings import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.codesystems.HttpVerb @@ -30,15 +31,30 @@ internal class UrlRequestGenerator( private val getUrlRequestForPatch: (patch: Patch) -> UrlUploadRequest, ) : UploadRequestGenerator { + /** + * Since a [UrlUploadRequest] can only handle a single resource request, the + * [StronglyConnectedPatchMappings.patchMappings] are flattened and handled as acyclic mapping to + * generate [UrlUploadRequestMapping] for each [PatchMapping]. + * + * **NOTE** + * + * Since the referential integrity on the sever may get violated if the subsequent requests have + * cyclic dependency on each other, We may introduce configuration for application to provide + * server's referential integrity settings and make it illegal to generate [UrlUploadRequest] when + * server has strict referential integrity and the requests have cyclic dependency amongst itself. + */ override fun generateUploadRequests( - mappedPatches: List, + mappedPatches: List, ): List = - mappedPatches.map { - UrlUploadRequestMapping( - localChanges = it.localChanges, - generatedRequest = getUrlRequestForPatch(it.generatedPatch), - ) - } + mappedPatches + .map { it.patchMappings } + .flatten() + .map { + UrlUploadRequestMapping( + localChanges = it.localChanges, + generatedRequest = getUrlRequestForPatch(it.generatedPatch), + ) + } companion object Factory { diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PatchOrderingTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PatchOrderingTest.kt index 44f8889dc8..f6bcae7883 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PatchOrderingTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PatchOrderingTest.kt @@ -28,8 +28,6 @@ import com.google.android.fhir.versionId import com.google.common.truth.Truth.assertThat import java.time.Instant import java.util.LinkedList -import kotlin.random.Random -import kotlin.test.assertFailsWith import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Group @@ -185,7 +183,7 @@ class PatchOrderingTest { // This order is based on the current implementation of the topological sort in [PatchOrdering], // it's entirely possible to generate different order here which is acceptable/correct, should // we have a different implementation of the topological sort. - assertThat(result.map { it.generatedPatch.resourceId }) + assertThat(result.map { it.patchMappings.single().generatedPatch.resourceId }) .containsExactly( "patient-1", "patient-2", @@ -202,53 +200,46 @@ class PatchOrderingTest { } @Test - fun `generate with cyclic references should throw exception`() = runTest { - val localChanges = LinkedList() - val localChangeResourceReferences = mutableListOf() + fun `generate with cyclic and acyclic references should generate both Individual and Combined mappings`() = + runTest { + val helper = LocalChangeHelper() - Patient() - .apply { - id = "patient-1" - addLink( - Patient.PatientLinkComponent().apply { other = Reference("RelatedPerson/related-1") }, - ) - } - .also { - localChanges.add(createInsertLocalChange(it, Random.nextLong())) - localChangeResourceReferences.add( - LocalChangeResourceReference( - localChanges.last().token.ids.first(), - "RelatedPerson/related-1", - "Patient.other", - ), - ) - } - - RelatedPerson() - .apply { - id = "related-1" - patient = Reference("Patient/patient-1") - } - .also { - localChanges.add(createInsertLocalChange(it)) - localChangeResourceReferences.add( - LocalChangeResourceReference( - localChanges.last().token.ids.first(), - "Patient/patient-1", - "RelatedPerson.patient", - ), - ) - } + // Patient and RelatedPerson have cyclic dependency + helper.createPatient("patient-1", 1, "related-1") + helper.createRelatedPerson("related-1", 2, "Patient/patient-1") - whenever(database.getLocalChangeResourceReferences(any())) - .thenReturn(localChangeResourceReferences) + // Patient, RelatedPerson have cyclic dependency. Observation, Encounter and Patient have + // acyclic dependency and order doesn't matter since they all go in same bundle. + helper.createPatient("patient-2", 3, "related-2") + helper.createRelatedPerson("related-2", 4, "Patient/patient-2") + helper.createObservation("observation-1", 5, "Patient/patient-2", "Encounter/encounter-1") + helper.createEncounter("encounter-1", 6, "Patient/patient-2") - val errorMessage = - assertFailsWith { patchGenerator.generate(localChanges) } - .localizedMessage + // observation , encounter and Patient have acyclic dependency with each other, hence order is + // important here. + helper.createObservation("observation-2", 7, "Patient/patient-3", "Encounter/encounter-2") + helper.createEncounter("encounter-2", 8, "Patient/patient-3") + helper.createPatient("patient-3", 9) - assertThat(errorMessage).isEqualTo("Detected a cycle.") - } + whenever(database.getLocalChangeResourceReferences(any())) + .thenReturn(helper.localChangeResourceReferences) + + val result = patchGenerator.generate(helper.localChanges) + + assertThat( + result.map { it.patchMappings.map { it.generatedPatch.resourceId } }, + ) + .containsExactly( + listOf("patient-1", "related-1"), + listOf("patient-2", "related-2"), + listOf("encounter-1"), + listOf("observation-1"), + listOf("patient-3"), + listOf("encounter-2"), + listOf("observation-2"), + ) + .inOrder() + } companion object { @@ -319,10 +310,29 @@ class PatchOrderingTest { fun createPatient( id: String, changeId: Long, + relatedPersonId: String? = null, ) = Patient() - .apply { this.id = id } - .also { localChanges.add(createInsertLocalChange(it, changeId)) } + .apply { + this.id = id + relatedPersonId?.let { + addLink( + Patient.PatientLinkComponent().apply { other = Reference("RelatedPerson/$it") }, + ) + } + } + .also { + localChanges.add(createInsertLocalChange(it, changeId)) + relatedPersonId?.let { + localChangeResourceReferences.add( + LocalChangeResourceReference( + localChanges.last().token.ids.first(), + "RelatedPerson/$relatedPersonId", + "Patient.other", + ), + ) + } + } fun updatePatient( patient: Patient, @@ -383,5 +393,26 @@ class PatchOrderingTest { ), ) } + + fun createRelatedPerson( + id: String, + changeId: Long, + patient: String, + ) = + RelatedPerson() + .apply { + this.id = id + this.patient = Reference(patient) + } + .also { + localChanges.add(createInsertLocalChange(it, changeId)) + localChangeResourceReferences.add( + LocalChangeResourceReference( + localChanges.last().token.ids.first(), + patient, + "RelatedPerson.patient", + ), + ) + } } } 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 4860b66cdc..38811c581d 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 @@ -69,7 +69,8 @@ class PerResourcePatchGeneratorTest { val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") val insertionLocalChange = createInsertLocalChange(patient) - val patches = patchGenerator.generate(listOf(insertionLocalChange)) + val patches = + patchGenerator.generate(listOf(insertionLocalChange)).map { it.patchMappings.single() } with(patches.single()) { with(generatedPatch) { @@ -100,7 +101,8 @@ class PerResourcePatchGeneratorTest { val updateLocalChange1 = createUpdateLocalChange(remotePatient, updatedPatient1, 1L) val updatePatch = readJsonArrayFromFile("/update_patch_1.json") - val patches = patchGenerator.generate(listOf(updateLocalChange1)) + val patches = + patchGenerator.generate(listOf(updateLocalChange1)).map { it.patchMappings.single() } with(patches.single()) { with(generatedPatch) { @@ -129,7 +131,8 @@ class PerResourcePatchGeneratorTest { remotePatient.meta = remoteMeta val deleteLocalChange = createDeleteLocalChange(remotePatient, 3L) - val patches = patchGenerator.generate(listOf(deleteLocalChange)) + val patches = + patchGenerator.generate(listOf(deleteLocalChange)).map { it.patchMappings.single() } with(patches.single()) { with(generatedPatch) { @@ -155,7 +158,10 @@ class PerResourcePatchGeneratorTest { val updateLocalChange = createUpdateLocalChange(patient, updatedPatient, 1L) val patientString = jsonParser.encodeResourceToString(updatedPatient) - val patches = patchGenerator.generate(listOf(insertionLocalChange, updateLocalChange)) + val patches = + patchGenerator.generate(listOf(insertionLocalChange, updateLocalChange)).map { + it.patchMappings.single() + } with(patches.single()) { with(generatedPatch) { @@ -312,7 +318,10 @@ class PerResourcePatchGeneratorTest { val updateLocalChange2 = createUpdateLocalChange(updatedPatient1, updatedPatient2, 2L) val updatePatch = readJsonArrayFromFile("/update_patch_2.json") - val patches = patchGenerator.generate(listOf(updateLocalChange1, updateLocalChange2)) + val patches = + patchGenerator.generate(listOf(updateLocalChange1, updateLocalChange2)).map { + it.patchMappings.single() + } with(patches.single()) { with(generatedPatch) { @@ -357,7 +366,10 @@ class PerResourcePatchGeneratorTest { token = LocalChangeToken(listOf(1)), ) - val patches = patchGenerator.generate(listOf(updatedLocalChange1, updatedLocalChange2)) + val patches = + patchGenerator.generate(listOf(updatedLocalChange1, updatedLocalChange2)).map { + it.patchMappings.single() + } with(patches.single().generatedPatch) { assertThat(type).isEqualTo(Patch.Type.UPDATE) @@ -385,9 +397,11 @@ class PerResourcePatchGeneratorTest { val deleteLocalChange = createDeleteLocalChange(updatedPatient2, 3L) val patches = - patchGenerator.generate( - listOf(updateLocalChange1, updateLocalChange2, deleteLocalChange), - ) + patchGenerator + .generate( + listOf(updateLocalChange1, updateLocalChange2, deleteLocalChange), + ) + .map { it.patchMappings.single() } with(patches.single()) { with(generatedPatch) { diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/StronglyConnectedPatchesTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/StronglyConnectedPatchesTest.kt new file mode 100644 index 0000000000..382dd65e71 --- /dev/null +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/StronglyConnectedPatchesTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.sync.upload.patch + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StronglyConnectedPatchesTest { + + @Test + fun `sscOrdered should return strongly connected components in order`() { + val graph = mutableMapOf>() + + graph.addEdge("0", "1") + graph.addEdge("1", "2") + graph.addEdge("2", "1") + + graph.addEdge("3", "4") + graph.addEdge("4", "5") + graph.addEdge("5", "3") + + graph.addEdge("6", "7") + graph.addEdge("7", "8") + + val result = StronglyConnectedPatches.scc(graph) + + assertThat(result) + .containsExactly( + listOf("1", "2"), + listOf("0"), + listOf("3", "4", "5"), + listOf("8"), + listOf("7"), + listOf("6"), + ) + .inOrder() + } + + @Test + fun `sscOrdered empty graph should return empty result`() { + val graph = mutableMapOf>() + val result = StronglyConnectedPatches.scc(graph) + assertThat(result).isEmpty() + } + + @Test + fun `sscOrdered graph with single node should return single scc`() { + val graph = mutableMapOf>() + graph.addNode("0") + val result = StronglyConnectedPatches.scc(graph) + assertThat(result).containsExactly(listOf("0")) + } + + @Test + fun `sscOrdered graph with two node should return two scc`() { + val graph = mutableMapOf>() + graph.addNode("0") + graph.addNode("1") + val result = StronglyConnectedPatches.scc(graph) + assertThat(result).containsExactly(listOf("0"), listOf("1")) + } + + @Test + fun `sscOrdered graph with two acyclic node should return two scc in order`() { + val graph = mutableMapOf>() + graph.addEdge("1", "0") + val result = StronglyConnectedPatches.scc(graph) + assertThat(result).containsExactly(listOf("0"), listOf("1")).inOrder() + } + + @Test + fun `sscOrdered graph with two cyclic node should return single scc`() { + val graph = mutableMapOf>() + graph.addEdge("0", "1") + graph.addEdge("1", "0") + val result = StronglyConnectedPatches.scc(graph) + assertThat(result).containsExactly(listOf("0", "1")) + } +} + +private fun Graph.addEdge(node: Node, dependsOn: Node) { + (this as MutableMap).getOrPut(node) { mutableListOf() }.let { (it as MutableList).add(dependsOn) } +} + +private fun Graph.addNode(node: Node) { + (this as MutableMap)[node] = mutableListOf() +} 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 0e02b6261a..92cded4e1d 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 @@ -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,6 +17,7 @@ package com.google.android.fhir.sync.upload.request import com.google.android.fhir.sync.upload.patch.PatchMapping +import com.google.android.fhir.sync.upload.patch.StronglyConnectedPatchMappings 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 @@ -49,7 +50,7 @@ class IndividualGeneratorTest { ) val requests = generator.generateUploadRequests( - listOf(patchOutput), + listOf(StronglyConnectedPatchMappings(listOf(patchOutput))), ) with(requests.single()) { @@ -72,7 +73,7 @@ class IndividualGeneratorTest { ) val requests = generator.generateUploadRequests( - listOf(patchOutput), + listOf(StronglyConnectedPatchMappings(listOf(patchOutput))), ) with(requests.single()) { @@ -92,7 +93,10 @@ class IndividualGeneratorTest { generatedPatch = updateLocalChange.toPatch(), ) val generator = UrlRequestGenerator.Factory.getDefault() - val requests = generator.generateUploadRequests(listOf(patchOutput)) + val requests = + generator.generateUploadRequests( + listOf(StronglyConnectedPatchMappings(listOf(patchOutput))), + ) with(requests.single()) { with(generatedRequest) { assertThat(requests.size).isEqualTo(1) @@ -115,7 +119,10 @@ class IndividualGeneratorTest { generatedPatch = deleteLocalChange.toPatch(), ) val generator = UrlRequestGenerator.Factory.getDefault() - val requests = generator.generateUploadRequests(listOf(patchOutput)) + val requests = + generator.generateUploadRequests( + listOf(StronglyConnectedPatchMappings(listOf(patchOutput))), + ) with(requests.single()) { with(generatedRequest) { assertThat(httpVerb).isEqualTo(HttpVerb.DELETE) @@ -132,7 +139,10 @@ class IndividualGeneratorTest { PatchMapping(listOf(it), it.toPatch()) } val generator = UrlRequestGenerator.Factory.getDefault() - val result = generator.generateUploadRequests(patchOutputList) + val result = + generator.generateUploadRequests( + patchOutputList.map { StronglyConnectedPatchMappings(listOf(it)) }, + ) assertThat(result).hasSize(3) assertThat(result.map { it.generatedRequest.httpVerb }) .containsExactly(HttpVerb.PUT, HttpVerb.PATCH, HttpVerb.DELETE) 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 6319ac0eee..998ffcb4e5 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 @@ -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,11 +16,10 @@ 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.PatchMapping +import com.google.android.fhir.sync.upload.patch.StronglyConnectedPatchMappings 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 @@ -50,9 +49,10 @@ class TransactionBundleGeneratorTest { fun `generateUploadRequests() should return single Transaction Bundle with 3 entries`() = runBlocking { val patches = - listOf(insertionLocalChange, updateLocalChange, deleteLocalChange).map { - PatchMapping(listOf(it), it.toPatch()) - } + listOf(insertionLocalChange, updateLocalChange, deleteLocalChange) + .map { PatchMapping(listOf(it), it.toPatch()) } + .map { StronglyConnectedPatchMappings(listOf(it)) } + val generator = TransactionBundleGenerator.Factory.getDefault() val result = generator.generateUploadRequests(patches) @@ -72,11 +72,10 @@ class TransactionBundleGeneratorTest { @Test fun `generateUploadRequests() should return 3 Transaction Bundle with single entry each`() = runBlocking { - val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val patches = - listOf(insertionLocalChange, updateLocalChange, deleteLocalChange).map { - PatchMapping(listOf(it), it.toPatch()) - } + listOf(insertionLocalChange, updateLocalChange, deleteLocalChange) + .map { PatchMapping(listOf(it), it.toPatch()) } + .map { StronglyConnectedPatchMappings(listOf(it)) } val generator = TransactionBundleGenerator.Factory.getGenerator( Bundle.HTTPVerb.PUT, @@ -119,11 +118,12 @@ class TransactionBundleGeneratorTest { ) val patches = listOf( - PatchMapping( - localChanges = listOf(localChange), - generatedPatch = localChange.toPatch(), - ), - ) + PatchMapping( + localChanges = listOf(localChange), + generatedPatch = localChange.toPatch(), + ), + ) + .map { StronglyConnectedPatchMappings(listOf(it)) } val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = false) val result = generator.generateUploadRequests(patches) @@ -147,11 +147,12 @@ class TransactionBundleGeneratorTest { ) val patches = listOf( - PatchMapping( - localChanges = listOf(localChange), - generatedPatch = localChange.toPatch(), - ), - ) + PatchMapping( + localChanges = listOf(localChange), + generatedPatch = localChange.toPatch(), + ), + ) + .map { StronglyConnectedPatchMappings(listOf(it)) } val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = true) val result = generator.generateUploadRequests(patches) @@ -185,7 +186,10 @@ class TransactionBundleGeneratorTest { token = LocalChangeToken(listOf(2L)), ), ) - val patches = localChanges.map { PatchMapping(listOf(it), it.toPatch()) } + val patches = + localChanges + .map { PatchMapping(listOf(it), it.toPatch()) } + .map { StronglyConnectedPatchMappings(listOf(it)) } val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = true) val result = generator.generateUploadRequests(patches) @@ -334,4 +338,146 @@ class TransactionBundleGeneratorTest { } assertThat(exception.localizedMessage).isEqualTo("Update using PUT is not supported.") } + + @Test + fun `generate() should not split changes in multiple bundle if combined mapping group has more patches than the permitted size`() = + runBlocking { + val localChange = + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = "Patient-00", + type = LocalChange.Type.UPDATE, + payload = "[]", + versionId = "patient-002-version-", + timestamp = Instant.now(), + token = LocalChangeToken(listOf(1L)), + ) + val patchGroups = + List(10) { + PatchMapping( + localChanges = + listOf( + localChange.copy( + resourceId = "Patient-00-$it", + versionId = "patient-002-version-$it", + ), + ), + generatedPatch = localChange.toPatch(), + ) + } + .let { StronglyConnectedPatchMappings(it) } + val generator = + TransactionBundleGenerator.Factory.getDefault(useETagForUpload = false, bundleSize = 5) + val result = generator.generateUploadRequests(listOf(patchGroups)) + + assertThat(result).hasSize(1) + assertThat(result.single().localChanges.size).isEqualTo(10) + } + + @Test + fun `generate() should put group mappings in respective bundles`() = runBlocking { + val localChange = + LocalChange( + resourceType = ResourceType.Patient.name, + resourceId = "Patient-00", + type = LocalChange.Type.UPDATE, + payload = "[]", + versionId = "patient-002-version-", + timestamp = Instant.now(), + token = LocalChangeToken(listOf(1L)), + ) + + val firstGroup = + StronglyConnectedPatchMappings( + mutableListOf().apply { + for (i in 1..5) { + add( + PatchMapping( + localChanges = + listOf( + localChange.copy( + resourceId = "Patient-00-$i", + versionId = "patient-002-version-$i", + ), + ), + generatedPatch = localChange.toPatch(), + ), + ) + } + }, + ) + + val secondGroup = + StronglyConnectedPatchMappings( + listOf( + PatchMapping( + localChanges = + listOf( + localChange.copy(resourceId = "Patient-00-6", versionId = "patient-002-version-7"), + ), + generatedPatch = localChange.toPatch(), + ), + ), + ) + + val thirdGroup = + StronglyConnectedPatchMappings( + listOf( + PatchMapping( + localChanges = + listOf( + localChange.copy(resourceId = "Patient-00-7", versionId = "patient-002-version-8"), + ), + generatedPatch = localChange.toPatch(), + ), + ), + ) + val fourthGroup = + StronglyConnectedPatchMappings( + mutableListOf().apply { + for (i in 9..13) { + add( + PatchMapping( + localChanges = + listOf( + localChange.copy( + resourceId = "Patient-00-$i", + versionId = "patient-002-version-$i", + ), + ), + generatedPatch = localChange.toPatch(), + ), + ) + } + }, + ) + + val patchGroups = listOf(firstGroup, secondGroup, thirdGroup, fourthGroup) + val generator = + TransactionBundleGenerator.Factory.getDefault(useETagForUpload = false, bundleSize = 5) + val result = generator.generateUploadRequests(patchGroups) + + assertThat(result).hasSize(3) + assertThat(result[0].localChanges.map { it.resourceId }) + .containsExactly( + "Patient-00-1", + "Patient-00-2", + "Patient-00-3", + "Patient-00-4", + "Patient-00-5", + ) + .inOrder() + assertThat(result[1].localChanges.map { it.resourceId }) + .containsExactly("Patient-00-6", "Patient-00-7") + .inOrder() + assertThat(result[2].localChanges.map { it.resourceId }) + .containsExactly( + "Patient-00-9", + "Patient-00-10", + "Patient-00-11", + "Patient-00-12", + "Patient-00-13", + ) + .inOrder() + } } From 70fd2aec1e6bf04b75cb95fb6ffe31ba848c6500 Mon Sep 17 00:00:00 2001 From: aditya-07 Date: Fri, 14 Jun 2024 01:35:09 +0530 Subject: [PATCH 02/10] Added rules for required for engine and datacapture libraries. (#1916) * Added rules for required for engine and datacatpure * Added consumerProguardFile for engine and catalog library * Review comments: Removed unnecessary rules * Review comments: Added comments * Enabled minfying for catalog and demo release apps. Also removed unnecessary keep rule from catalog's rule file * updated rules * Added comments about new rules --- catalog/build.gradle.kts | 4 ++-- catalog/proguard-rules.pro | 21 ++++++++++++++++++ datacapture/build.gradle.kts | 1 + datacapture/proguard-rules.pro | 11 ++++++++++ demo/build.gradle.kts | 2 +- engine/build.gradle.kts | 1 + engine/proguard-rules.pro | 39 +++++++++++++++++----------------- 7 files changed, 57 insertions(+), 22 deletions(-) create mode 100644 catalog/proguard-rules.pro create mode 100644 datacapture/proguard-rules.pro diff --git a/catalog/build.gradle.kts b/catalog/build.gradle.kts index 3287f3b4d8..2e1fe9a966 100644 --- a/catalog/build.gradle.kts +++ b/catalog/build.gradle.kts @@ -22,8 +22,8 @@ android { buildTypes { release { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } compileOptions { diff --git a/catalog/proguard-rules.pro b/catalog/proguard-rules.pro new file mode 100644 index 0000000000..0deded0be5 --- /dev/null +++ b/catalog/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.kts +# +# 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 diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts index 56ec2b54f2..31bf97ba65 100644 --- a/datacapture/build.gradle.kts +++ b/datacapture/build.gradle.kts @@ -21,6 +21,7 @@ android { testInstrumentationRunner = Dependencies.androidJunitRunner // Need to specify this to prevent junit runner from going deep into our dependencies testInstrumentationRunnerArguments["package"] = "com.google.android.fhir.datacapture" + consumerProguardFile("proguard-rules.pro") } buildFeatures { viewBinding = true } diff --git a/datacapture/proguard-rules.pro b/datacapture/proguard-rules.pro new file mode 100644 index 0000000000..8ef05d43fe --- /dev/null +++ b/datacapture/proguard-rules.pro @@ -0,0 +1,11 @@ +## HAPI Strucutres need to be kept beause + # 1. Reflection is used in resource extraction as the FHIR Path expression is then translated to field names to locate data elements in FHIR resources. + # 2. In HAPI Strucures, ClassLoader is used to load classes from different packages that are hardcoded. +-keep class ca.uhn.fhir.** { *; } +-keep class org.hl7.fhir.** { *; } +# Used by hapi's XmlUtil which is internally used by hapi's FHIRPathEngine. +-keep class com.ctc.wstx.stax.** { *; } +# Used by HapiWorkerContext (fhirpath engine in QuestionnaireViewModel) +-keep class com.github.benmanes.caffeine.cache.** { *; } +## hapi libs ends +-ignorewarnings diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 8c2ee36a28..7cf86019d7 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -19,7 +19,7 @@ android { } buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 4dbccd672c..24b24ada6c 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -38,6 +38,7 @@ android { testInstrumentationRunner = Dependencies.androidJunitRunner // need to specify this to prevent junit runner from going deep into our dependencies testInstrumentationRunnerArguments["package"] = "com.google.android.fhir" + consumerProguardFile("proguard-rules.pro") javaCompileOptions { annotationProcessorOptions { diff --git a/engine/proguard-rules.pro b/engine/proguard-rules.pro index 2f9dc5a47e..46b0e8bef0 100644 --- a/engine/proguard-rules.pro +++ b/engine/proguard-rules.pro @@ -1,21 +1,22 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +## HAPI Strucutres need to be kept beause + # 1. Reflection is used in resource extraction as the FHIR Path expression is then translated to field names to locate data elements in FHIR resources. + # 2. In HAPI Strucures, ClassLoader is used to load classes from different packages that are hardcoded. +-keep class ca.uhn.fhir.** { *; } +-keep class org.hl7.fhir.** { *; } +# Used by hapi's XmlUtil which is internally used by hapi's FHIRPathEngine. +-keep class com.ctc.wstx.stax.** { *; } +# Used by HapiWorkerContext (fhirpath engine in QuestionnaireViewModel) +-keep class com.github.benmanes.caffeine.cache.** { *; } +## hapi libs ends -# 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 *; -#} +## sqlcipher starts +# see (https://github.com/sqlcipher/android-database-sqlcipher/tree/master#proguard) +-keep,includedescriptorclasses class net.sqlcipher.** { *; } +-keep,includedescriptorclasses interface net.sqlcipher.** { *; } +## sqlcipher ends -# 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 +## retrofit starts +# see (https://github.com/square/retrofit/issues/3539) +-keep class com.google.android.fhir.sync.remote.RetrofitHttpService { *; } +## retrofit ends +-ignorewarnings From d3841ebd636305a4f4c9e869bf94cb11959628a1 Mon Sep 17 00:00:00 2001 From: Hugo Miloszewski Date: Tue, 18 Jun 2024 13:34:54 +0100 Subject: [PATCH 03/10] Added SHL Decoding Interfaces and Implementations (#2434) * Added interfaces and utils needed for decoding * Separated decoderImpl into smaller functions and added more tests for readShlUtils * Started implementing tests for decoderImpl * Finished unit tests for ReadSHLinkUtils * Wrote test for SHLinkDecoder * Added comments and cleaned code * More DecoderImpl unit tests added * Ran gradle checks * Removed constructShl function from impl and SHLScanDataInput from Decoder parameters - refactored tests to acommodate this * Changed test names to use backticks to make them more readable * Migrating asserts to the Truth library * Migrated all decoding tests to use Truth library * Added helpful comments to DecoderImpl * Added kdoc to the Decoder interface * Changed Base64 library from java to android - so minSdk can be kept at 24 * Changed Retrofit object variable name to be more descriptive * Extracted common variables in decoderImpl tests * Changed shLinkScanData create function to be inside the companion object and removed public constructors * Improved kdoc in interface * Refactored decodeSHLink to take in passcode and recipient as separate strings - instead of a predefined JSON object * Ran gradle checks * Correct the IPSDocument create function * Added named parameter in IPSDocument create function * Changed tests to use assertThrows and improved decoder tests --------- Co-authored-by: aditya-07 --- document/build.gradle.kts | 1 + .../IPSDocument.kt | 27 +- .../RetrofitSHLService.kt | 43 ++- .../decode/ReadSHLinkUtils.kt | 109 +++++++ .../decode/SHLinkDecoder.kt | 90 ++++++ .../decode/SHLinkDecoderImpl.kt | 168 +++++++++++ .../generate/SHLinkGeneratorImpl.kt | 3 +- .../scan/SHLinkScanData.kt | 79 +++++ .../fhir/document/EncryptionUtilsTest.kt | 4 +- .../fhir/document/ReadSHLinkUtilsTest.kt | 154 ++++++++++ .../fhir/document/SHLinkDecoderImplTest.kt | 274 ++++++++++++++++++ 11 files changed, 919 insertions(+), 33 deletions(-) create mode 100644 document/src/main/java/com.google.android.fhir.document/decode/ReadSHLinkUtils.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/decode/SHLinkDecoder.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/decode/SHLinkDecoderImpl.kt create mode 100644 document/src/main/java/com.google.android.fhir.document/scan/SHLinkScanData.kt create mode 100644 document/src/test/java/com/google/android/fhir/document/ReadSHLinkUtilsTest.kt create mode 100644 document/src/test/java/com/google/android/fhir/document/SHLinkDecoderImplTest.kt diff --git a/document/build.gradle.kts b/document/build.gradle.kts index 6001760384..000bebbfbe 100644 --- a/document/build.gradle.kts +++ b/document/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.Kotlin.kotlinCoroutinesTest) testImplementation(Dependencies.mockWebServer) + testImplementation(Dependencies.truth) androidTestImplementation(Dependencies.AndroidxTest.extJunit) androidTestImplementation(Dependencies.Espresso.espressoCore) 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 index 2845b23206..78582d08e9 100644 --- a/document/src/main/java/com.google.android.fhir.document/IPSDocument.kt +++ b/document/src/main/java/com.google.android.fhir.document/IPSDocument.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,9 @@ 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.Composition import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType /** * Represents an International Patient Summary (IPS) document, associating it with a specific @@ -31,13 +32,29 @@ import org.hl7.fhir.r4.model.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, -) +) { + companion object { + fun create(bundle: Bundle): IPSDocument { + if (bundle.entry.isNotEmpty()) { + val composition = + bundle.entry + ?.firstOrNull { it.resource.resourceType == ResourceType.Composition } + ?.resource as Composition + val titles = + composition.section.map { + val titleText = it.title ?: "Unknown Section" + Title(titleText, ArrayList()) + } as ArrayList<Title> + return IPSDocument(bundle, titles) + } + return IPSDocument(bundle, titles = arrayListOf()) + } + } +} /** * Represents a title, which corresponds to a section present in the IPS document. 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 index 27449d35b7..e7a45db147 100644 --- a/document/src/main/java/com.google.android.fhir.document/RetrofitSHLService.kt +++ b/document/src/main/java/com.google.android.fhir.document/RetrofitSHLService.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,16 +18,17 @@ 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 org.json.JSONObject import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.Body +import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.Headers import retrofit2.http.POST @@ -53,16 +54,26 @@ interface RetrofitSHLService { @Header("Authorization") authorization: String, ): Response<ResponseBody> + /* POST request to the SHL's manifest url to get the list of files associated with the link */ + @POST + @Headers("Content-Type: application/json") + suspend fun getFilesFromManifest( + @Url path: String, + @Body jsonData: JSONObject, + ): Response<ResponseBody> + + /* GET request if files are stored in an external "location" */ + @GET + suspend fun getFromLocation( + @Url path: 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() @@ -83,25 +94,5 @@ interface RetrofitSHLService { .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/decode/ReadSHLinkUtils.kt b/document/src/main/java/com.google.android.fhir.document/decode/ReadSHLinkUtils.kt new file mode 100644 index 0000000000..a2b648467c --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/decode/ReadSHLinkUtils.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.document.decode + +import android.util.Base64 +import com.nimbusds.jose.JWEDecrypter +import com.nimbusds.jose.JWEObject +import com.nimbusds.jose.crypto.DirectDecrypter +import java.io.ByteArrayOutputStream +import java.util.zip.DataFormatException +import java.util.zip.Inflater +import org.json.JSONObject + +object ReadSHLinkUtils { + + /* Extracts the part of the link after the 'shlink:/' */ + fun extractUrl(scannedData: String): String { + if (scannedData.contains("shlink:/")) { + return scannedData.substringAfterLast("shlink:/") + } + throw IllegalArgumentException("Not a valid SHLink") + } + + /* Decodes the extracted url from Base64Url to a byte array */ + fun decodeUrl(extractedUrl: String): ByteArray { + if (extractedUrl.isEmpty()) { + throw IllegalArgumentException("Not a valid Base64 encoded string") + } + try { + return Base64.decode(extractedUrl.toByteArray(), Base64.URL_SAFE) + } catch (err: IllegalArgumentException) { + throw IllegalArgumentException("Not a valid Base64 encoded string") + } + } + + /* Returns a string of data found in the verifiableCredential field in the given JSON */ + fun extractVerifiableCredential(jsonString: String): String { + val jsonObject = JSONObject(jsonString) + if (jsonObject.has("verifiableCredential")) { + val verifiableCredentialArray = jsonObject.getJSONArray("verifiableCredential") + + if (verifiableCredentialArray.length() > 0) { + // Assuming you want the first item from the array + return verifiableCredentialArray.getString(0) + } + } + return "" + } + + /* Decodes and decompresses the payload in a JWT token */ + fun decodeAndDecompressPayload(token: String): String { + try { + val tokenParts = token.split('.') + if (tokenParts.size < 2) { + throw Error("Invalid JWT token passed in") + } + val decoded = Base64.decode(tokenParts[1], Base64.URL_SAFE) + val inflater = Inflater(true) + inflater.setInput(decoded) + val initialBufferSize = 100000 + val decompressedBytes = ByteArrayOutputStream(initialBufferSize) + val buffer = ByteArray(8192) + + try { + while (!inflater.finished()) { + val length = inflater.inflate(buffer) + decompressedBytes.write(buffer, 0, length) + } + decompressedBytes.close() + } catch (e: DataFormatException) { + throw Error("$e.printStackTrace()") + } + inflater.end() + return decompressedBytes.toByteArray().decodeToString() + } catch (err: Error) { + throw Error("Invalid JWT token passed in: $err") + } + } + + /* Decodes and decompresses the embedded health data from a JWE token into a string */ + fun decodeShc(responseBody: String, key: String): String { + try { + if (responseBody.isEmpty() or key.isEmpty()) { + throw IllegalArgumentException("The provided strings should not be empty") + } + val jweObject = JWEObject.parse(responseBody) + val decodedKey: ByteArray = Base64.decode(key, Base64.URL_SAFE) + val decrypter: JWEDecrypter = DirectDecrypter(decodedKey) + jweObject.decrypt(decrypter) + return jweObject.payload.toString() + } catch (err: Exception) { + throw Exception("JWE decryption failed: $err") + } + } +} diff --git a/document/src/main/java/com.google.android.fhir.document/decode/SHLinkDecoder.kt b/document/src/main/java/com.google.android.fhir.document/decode/SHLinkDecoder.kt new file mode 100644 index 0000000000..14fe96dd1e --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/decode/SHLinkDecoder.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.document.decode + +import com.google.android.fhir.document.IPSDocument + +/** + * The [SHLinkDecoder] interface defines a contract for decoding Smart Health Links (SHLs) into + * [IPSDocument] objects. Implementations of this interface are responsible for decoding and + * decompressing SHLs, fetching associated health data from external sources, and creating + * IPSDocument instances. + * + * The process of decoding SHLs is outlined in its documentation + * [SHL Documentation](https://docs.smarthealthit.org/smart-health-links/). + * + * ## Example Decoding Process: + * A SHL is formatted as `[optionalViewer]shlink:/[Base64-Encoded Payload]` (e.g., + * `shlink:/eyJsYWJ...`). First, extract the portion of the link after 'shlink:/' and decode this to + * give a SHL Payload. SHL Payloads are structured as: + * ``` + * { + * "url": manifest url, + * "key": SHL-specific key, + * "label": "2023-07-12", + * "flag": "LPU", + * "exp": expiration time, + * "v": SHL Protocol Version + * } + * ``` + * + * The label, flag, exp, and v properties are optional. + * + * Send a POST request to the manifest URL with a header of "Content-Type":"application/json" and a + * body with a "Recipient", a "Passcode" if the "P" flag is present and optionally + * "embeddedLengthMax":INT. Example request body: + * + * ``` + * { + * "recipient" : "example_name", + * "passcode" : "example_passcode" + * } + * ``` + * ``` + * + * If the POST request is successful, a list of files is returned. + * Example response: + * + * ``` + * + * { "files" : + * [ { "contentType": "application/smart-health-card", "location":"https://bucket.cloud.example..." }, { "contentType": "application/smart-health-card", "embedded":"eyJhb..." } ] + * } + * + * ``` + * + * A file can be one of two types: + * - Location: If the resource is stored in a location, a single GET request can be made to retrieve the data. + * - Embedded: If the file type is embedded, the data is a JWE token which can be decoded with the SHL-specific key. + */ +interface SHLinkDecoder { + + /** + * Decodes and decompresses a Smart Health Link (SHL) into an [IPSDocument] object. + * + * @param shLink The full Smart Health Link. + * @param recipient The recipient for the manifest request. + * @param passcode The passcode for the manifest request (optional, will be null if the P flag is + * not present in the SHL payload). + * @return An [IPSDocument] object if decoding is successful, otherwise null. + */ + suspend fun decodeSHLinkToDocument( + shLink: String, + recipient: String, + passcode: String?, + ): IPSDocument? +} diff --git a/document/src/main/java/com.google.android.fhir.document/decode/SHLinkDecoderImpl.kt b/document/src/main/java/com.google.android.fhir.document/decode/SHLinkDecoderImpl.kt new file mode 100644 index 0000000000..cdd31d7088 --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/decode/SHLinkDecoderImpl.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.document.decode + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import com.google.android.fhir.document.IPSDocument +import com.google.android.fhir.document.RetrofitSHLService +import com.google.android.fhir.document.scan.SHLinkScanData +import kotlinx.coroutines.coroutineScope +import org.hl7.fhir.r4.model.Bundle +import org.json.JSONArray +import org.json.JSONObject + +class SHLinkDecoderImpl( + private val readSHLinkUtils: ReadSHLinkUtils, + private val retrofitSHLService: RetrofitSHLService, +) : SHLinkDecoder { + + private val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + + /** + * Decodes a Smart Health Link (SHL) to an [IPSDocument] object by creating a new [SHLinkScanData] + * object and posting the provided JSON data to its manifest URL. + * + * @param shLink The full Smart Health Link. + * @param recipient The recipient for the manifest request. + * @param passcode The passcode for the manifest request (optional, will be null if the P flag is + * not present in the SHL payload). + * @return An [IPSDocument] object if decoding is successful, otherwise null. + */ + override suspend fun decodeSHLinkToDocument( + shLink: String, + recipient: String, + passcode: String?, + ): IPSDocument? { + val shLinkScanData = SHLinkScanData.create(shLink) + val requestBody = + JSONObject().apply { + put("recipient", recipient) + if (passcode != null) { + put("passcode", passcode) + } + } + val bundle = postToServer(requestBody, shLinkScanData) + return if (bundle != null) { + IPSDocument.create(bundle) + } else { + null + } + } + + /** + * Posts the JSON data to the Smart Health Link's manifest URL. If successful, the response body, + * which will be a list of files, is decoded. + * + * @param requestBody The JSON data to be posted to the manifest. + * @param shLinkScanData The SHLinkScanData object containing information about the SHL. + * @return A FHIR Bundle representing the IPS Document. + * @throws Error if there's an issue posting to the manifest or decoding the response. + */ + private suspend fun postToServer( + requestBody: JSONObject, + shLinkScanData: SHLinkScanData, + ): Bundle? = coroutineScope { + try { + val response = + retrofitSHLService.getFilesFromManifest( + shLinkScanData.manifestUrl.substringAfterLast("shl/"), + requestBody, + ) + if (response.isSuccessful) { + val responseBody = response.body()?.string() + responseBody?.let { + return@coroutineScope decodeResponseBody(it, shLinkScanData) + } + } else { + throw (Error("HTTP Error: ${response.code()}")) + } + } catch (err: Throwable) { + throw (Error("Error posting to the manifest: $err")) + } + } + + /** + * Decodes the response body, handling both embedded files and files stored in an external + * location. + * + * @param responseBody The response body from the manifest request. + * @param shLinkScanData The SHLinkScanData object containing information about the SHL. + * @return A FHIR Bundle representing the IPS Document. + * @throws IllegalArgumentException if no data is found at the given location. + */ + private suspend fun decodeResponseBody( + responseBody: String, + shLinkScanData: SHLinkScanData, + ): Bundle { + val jsonObject = JSONObject(responseBody) + val embeddedArray = + jsonObject.getJSONArray("files").let { jsonArray: JSONArray -> + (0 until jsonArray.length()) + .mapNotNull { i -> + val fileObject = jsonArray.getJSONObject(i) + if (fileObject.has("embedded")) { + fileObject.getString("embedded") + } else { + fileObject.getString("location").let { + val responseFromLocation = + retrofitSHLService.getFromLocation("file/${it.substringAfterLast("/")}") + val responseBodyFromLocation = responseFromLocation.body()?.string() + if (!responseBodyFromLocation.isNullOrBlank()) { + responseBodyFromLocation + } else { + throw (IllegalArgumentException("No data found at the given location")) + } + } + } + } + .toTypedArray() + } + return decodeEmbeddedArray(embeddedArray, shLinkScanData) + } + + /** + * Decodes each element in the array and returns the FHIR Bundle stored inside. + * + * @param embeddedArray An array of encrypted data elements. + * @param shLinkScanData The SHLinkScanData object containing information about the SHL. + * @return A FHIR Bundle representing the IPS Document. + */ + private fun decodeEmbeddedArray( + embeddedArray: Array<String>, + shLinkScanData: SHLinkScanData, + ): Bundle { + var healthData = "" + for (element in embeddedArray) { + val decodedShc = shLinkScanData.key.let { readSHLinkUtils.decodeShc(element, it) } + if (decodedShc != "") { + val toDecode = readSHLinkUtils.extractVerifiableCredential(decodedShc) + if (toDecode.isEmpty()) { + healthData = decodedShc + break + } + val document = + JSONObject(readSHLinkUtils.decodeAndDecompressPayload(toDecode)) + .getJSONObject("vc") + .getJSONObject("credentialSubject") + .getJSONObject("fhirBundle") + return parser.parseResource(document.toString()) as Bundle + } + } + return parser.parseResource(healthData) as Bundle + } +} 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 index 998ed9e5de..1b60c5223d 100644 --- 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 @@ -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. @@ -62,6 +62,7 @@ internal class SHLinkGeneratorImpl( val response = apiService.getManifestUrlAndToken("", requestBody) return if (response.isSuccessful) { val responseBody = response.body()?.string() + println("MANIFEST RESPONSE: $responseBody") if (!responseBody.isNullOrBlank()) { JSONObject(responseBody) } else { diff --git a/document/src/main/java/com.google.android.fhir.document/scan/SHLinkScanData.kt b/document/src/main/java/com.google.android.fhir.document/scan/SHLinkScanData.kt new file mode 100644 index 0000000000..9b9bd1cdf0 --- /dev/null +++ b/document/src/main/java/com.google.android.fhir.document/scan/SHLinkScanData.kt @@ -0,0 +1,79 @@ +/* + * 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.document.scan + +import com.google.android.fhir.document.IPSDocument +import com.google.android.fhir.document.decode.ReadSHLinkUtils +import java.nio.charset.StandardCharsets +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber + +/** + * Represents a SHL data structure, which stores information related to SHL content required for the + * scanning process. + * + * 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 fullLink The full Smart Health Link (could include an optional SHL viewer). + * @property encodedShlPayload The Base64Url-encoded SHL payload. + * @property manifestUrl The URL to the SHL manifest. + * @property key The key for decoding the data. + * @property label A label describing the SHL data. + * @property flag Flags indicating specific conditions or requirements (e.g., "P" for passcode). + * @property expirationTime The expiration time of the SHL data. + * @property versionNumber The version number of the SHL data. + * @property ipsDoc The IPS document linked to by the SHL. + */ +data class SHLinkScanData( + val fullLink: String, + val encodedShlPayload: String, + val manifestUrl: String, + val key: String, + val label: String, + val flag: String, + val expirationTime: String, + val versionNumber: String, + val ipsDoc: IPSDocument?, +) { + companion object { + fun create(fullLink: String): SHLinkScanData { + val extractedJson = ReadSHLinkUtils.extractUrl(fullLink) + val decodedJson = ReadSHLinkUtils.decodeUrl(extractedJson) + + try { + val jsonObject = JSONObject(String(decodedJson, StandardCharsets.UTF_8)) + return SHLinkScanData( + fullLink, + extractedJson, + jsonObject.optString("url", ""), + jsonObject.optString("key", ""), + jsonObject.optString("label", ""), + jsonObject.optString("flag", ""), + jsonObject.optString("expirationTime", ""), + jsonObject.optString("versionNumber", ""), + null, + ) + } catch (exception: JSONException) { + Timber.e(exception, "Error creating JSONObject from decodedJson: $decodedJson") + throw exception + } + } + } +} 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 index ff9170931b..0064f218a7 100644 --- a/document/src/test/java/com/google/android/fhir/document/EncryptionUtilsTest.kt +++ b/document/src/test/java/com/google/android/fhir/document/EncryptionUtilsTest.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. @@ -26,8 +26,10 @@ import org.junit.Assert 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 EncryptionUtilsTest { private val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() diff --git a/document/src/test/java/com/google/android/fhir/document/ReadSHLinkUtilsTest.kt b/document/src/test/java/com/google/android/fhir/document/ReadSHLinkUtilsTest.kt new file mode 100644 index 0000000000..2491180256 --- /dev/null +++ b/document/src/test/java/com/google/android/fhir/document/ReadSHLinkUtilsTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.document + +import com.google.android.fhir.document.decode.ReadSHLinkUtils +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +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 ReadSHLinkUtilsTest { + private val readSHLinkUtils = ReadSHLinkUtils + + private val responseBody = + "eyJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiZGlyIn0..OgGwTWbECJk9tQc4.PUxr0STCtKQ6DmdPqPtJtTowTBxdprFykeZ2WUOUw234_TtdGWLJ0hzfuWjZXDyBpa55TXwvSwobpcbut9Cdl2nATA0_j1nW0-A32uAwH0qEE1ELV5G0IQVT5AqKJRTCMGpy0mWH.qATmrk-UdwCOaT1TY6GEJg" + private val key = "VmFndWVseS1FbmdhZ2luZy1QYXJhZG94LTA1NTktMDg" + + @Test + fun `test extractUrl with a valid SHL`() { + val scannedData = "shlink:/example-url" + val result = readSHLinkUtils.extractUrl(scannedData) + assertThat(result).isEqualTo("example-url") + } + + @Test + fun `test extractUrl with an invalid SHL`() { + val scannedData = "invalidSHL" + val result = + assertThrows(IllegalArgumentException::class.java) { readSHLinkUtils.extractUrl(scannedData) } + assertThat(result.message).isEqualTo("Not a valid SHLink") + } + + @Test + fun `test extractUrl with an empty SHL`() { + val scannedData = "" + val result = + assertThrows(IllegalArgumentException::class.java) { readSHLinkUtils.extractUrl(scannedData) } + assertThat(result.message).isEqualTo("Not a valid SHLink") + } + + @Test + fun `test that decodeUrl successfully decodes a Base64 encoded input string`() { + val extractedUrl = "aGVsbG8=" + val result = readSHLinkUtils.decodeUrl(extractedUrl) + assertThat(result).isEqualTo("hello".toByteArray()) + } + + @Test + fun `test that decodeUrl throws an error when decoding an invalid Base64 encoded input string`() { + val extractedUrl = "aGsbG8=" + val result = + assertThrows(IllegalArgumentException::class.java) { readSHLinkUtils.decodeUrl(extractedUrl) } + assertThat(result.message).isEqualTo("Not a valid Base64 encoded string") + } + + @Test + fun `test that decodeUrl throws an error when decoding an empty input string`() { + val extractedUrl = "" + val result = + assertThrows(IllegalArgumentException::class.java) { readSHLinkUtils.decodeUrl(extractedUrl) } + assertThat(result.message).isEqualTo("Not a valid Base64 encoded string") + } + + @Test + fun `test that decodeShc can successfully decrypt an SHL with it's given key`() { + val result = readSHLinkUtils.decodeShc(responseBody, key) + assertThat(result.trim()) + .isEqualTo( + "{\"iss\":\"DinoChiesa.github.io\",\"sub\":\"idris\",\"aud\":\"kina\",\"iat\":1691158997,\"exp\":1691159597,\"aaa\":true}", + ) + } + + @Test + fun `test that decodeShc unsuccessfully decrypts an empty string`() { + val responseBody = "" + val result = + assertThrows(Exception::class.java) { readSHLinkUtils.decodeShc(responseBody, key) } + assertThat(result.message).contains("JWE decryption failed") + } + + @Test + fun `test that decodeShc unsuccessfully decrypts an SHL with an empty key`() { + val key = "" + val result = + assertThrows(Exception::class.java) { readSHLinkUtils.decodeShc(responseBody, key) } + assertThat(result.message).contains("JWE decryption failed") + } + + @Test + fun `test that decodeShc unsuccessfully decrypts an SHL with an invalid key`() { + val invalidKey = "VmFndWVseS1FbmdhZ2luZy1QYXJhZG94LTA1NTktMDb" + val result = + assertThrows(Exception::class.java) { readSHLinkUtils.decodeShc(responseBody, invalidKey) } + assertThat(result.message).contains("JWE decryption failed") + } + + @Test + fun `test extractVerifiableCredential successfully extracts the data from a JSON string`() { + val jsonStringWithCredential = "{\"verifiableCredential\": [\"credentialData\"]}" + val resultWithCredential = readSHLinkUtils.extractVerifiableCredential(jsonStringWithCredential) + assertThat(resultWithCredential).isEqualTo("credentialData") + } + + @Test + fun `test extractVerifiableCredential successfully extracts the data from an empty JSON string`() { + val jsonStringWithoutCredential = "{}" + val resultWithoutCredential = + readSHLinkUtils.extractVerifiableCredential(jsonStringWithoutCredential) + assertThat(resultWithoutCredential).isEqualTo("") + } + + @Test + fun `test that JWTs can be decoded and decompressed into JSON data`() { + val jwt = + "eyJ6aXAiOiJERUYiLCJhbGciOiJFUzI1NiIsImtpZCI6IjNLZmRnLVh3UC03Z1h5eXd0VWZVQUR3QnVtRE9QS01ReC1pRUxMMTFXOXMifQ.pZJJT8MwEIX_ChquaZZSthyBAxwQSCwX1IPrTBsjL9HYKRSU_85MaAVCiAtSDnH85vN7z3kHEyPU0KbUxbooYoc6j05RalHZ1OZaURMLfFWusxgLVvdIkIFfLKGujman5bQ8mM7y6dFhBmsN9TukTYdQP30xf-L2PxcTWTDq_zrjXO_Nm0omeJhnoAkb9Mkoe9cvnlEnsbVsDT0iRdHUMMvLvGKofD3rfWNRNIQx9KTxfowA241sGwl0sJZpQsiAD6AN52Ryb-0DWRbs5uuSBbvFL-Bbtsrz0qNy-AlRztiNHErhRfgrs0YvPd5YfiOYD5xsYTj6hUoCmZbV8aSsJuUMhiH71Ub1t42r771lEJNKfRxzym0nlNbXSmvj8Tw0I0GHxvjV6DhuYkK3_Xn4Xlp7nAdaFVJpEU1T6PUrA_Q4CeUJDPMhg24bfXSzREIv1r43x6KgdU_jlmS9N-5HXsYgLQM57kWsKJ0CCbIxsbNKarxGMglp7zLEziRluaP5-AzDBw.xOwN6qSTeHU-FkqTIojbvryr8Ztue_HBbiiGdIcfio7m2-STuC-CdNIEt9WbxU_CpveZwdwdYlaQ3cX-yi-SQg" + val expectedData = + "{\"iss\":\"https://spec.smarthealth.cards/examples/issuer\",\"nbf\":1649020324.265,\"vc\":{\"type\":[\"https://smarthealth.cards#health-card\",\"https://smarthealth.cards#health-card\",\"https://smarthealth.cards#immunization\"],\"credentialSubject\":{\"fhirVersion\":\"4.0.1\",\"fhirBundle\":{\"resourceType\":\"Bundle\",\"type\":\"collection\",\"entry\":[{\"fullUrl\":\"resource:0\",\"resource\":{\"resourceType\":\"Patient\",\"name\":[{\"family\":\"Brown\",\"given\":[\"Oliver\"]}],\"birthDate\":\"2017-01-04\"}},{\"fullUrl\":\"resource:1\",\"resource\":{\"resourceType\":\"Immunization\",\"status\":\"completed\",\"vaccineCode\":{\"coding\":[{\"system\":\"http://hl7.org/fhir/sid/cvx\",\"code\":\"08\"}]},\"patient\":{\"reference\":\"resource:0\"},\"occurrenceDateTime\":\"2017-01-04\",\"performer\":[{\"actor\":{\"display\":\"Meriter Hospital\"}}]}}]}}}}\n" + val decoded = readSHLinkUtils.decodeAndDecompressPayload(jwt) + assertThat(expectedData.trim()).isEqualTo(decoded.trim()) + } + + @Test + fun `test that empty JWTs throw an error when attempting to decode and decompress them`() { + val jwt = "" + val result = assertThrows(Error::class.java) { readSHLinkUtils.decodeAndDecompressPayload(jwt) } + assertThat(result.message).contains("Invalid JWT token passed in:") + } + + @Test + fun `test that invalid JWTs throw an error when attempting to decode and decompress them`() { + val jwt = + "XAiOiJERUYiLCJhbGciOiJFUzI1NiIsImtpZCI6IjNLZmRnLVh3UC03Z1h5eXd0VWZVQUR3QnVtRE9QS01ReC1pRUxMMTFXOXMifQ.pHJJT8MwEIX_ChquaZZSthyBAxwQSCwX1IPrTBsjL9HYKRSU_85MaAVCiAtSDnH85vN7z3kHEyPU0KbUxbooYoc6j05RalHZ1OZaURMLfFWusxgLVvdIkIFfLKGujman5bQ8mM7y6dFhBmsN9TukTYdQP30xf-L2PxcTWTDq_zrjXO_Nm0omeJhnoAkb9Mkoe9cvnlEnsbVsDT0iRdHUMMvLvGKofD3rfWNRNIQx9KTxfowA241sGwl0sJZpQsiAD6AN52Ryb-0DWRbs5uuSBbvFL-Bbtsrz0qNy-AlRztiNHErhRfgrs0YvPd5YfiOYD5xsYTj6hUoCmZbV8aSsJuUMhiH71Ub1t42r771lEJNKfRxzym0nlNbXSmvj8Tw0I0GHxvjV6DhuYkK3_Xn4Xlp7nAdaFVJpEU1T6PUrA_Q4CeUJDPMhg24bfXSzREIv1r43x6KgdU_jlmS9N-5HXsYgLQM57kWsKJ0CCbIxsbNKarxGMglp7zLEziRluaP5-AzDBw.xOwN6qSTeHU-FkqTIojbvryr8Ztue_HBbiiGdIcfio7m2-STuC-CdNIEt9WbxU_CpveZwdwdYlaQ3cX-yi-SQg" + val result = assertThrows(Error::class.java) { readSHLinkUtils.decodeAndDecompressPayload(jwt) } + assertThat(result.message).contains("Invalid JWT token passed in:") + } +} diff --git a/document/src/test/java/com/google/android/fhir/document/SHLinkDecoderImplTest.kt b/document/src/test/java/com/google/android/fhir/document/SHLinkDecoderImplTest.kt new file mode 100644 index 0000000000..f001d1cca7 --- /dev/null +++ b/document/src/test/java/com/google/android/fhir/document/SHLinkDecoderImplTest.kt @@ -0,0 +1,274 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.document + +import com.google.android.fhir.NetworkConfiguration +import com.google.android.fhir.document.decode.ReadSHLinkUtils +import com.google.android.fhir.document.decode.SHLinkDecoderImpl +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.anyString +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 SHLinkDecoderImplTest { + + private lateinit var shLinkDecoderImpl: SHLinkDecoderImpl + + @Mock private lateinit var readSHLinkUtils: ReadSHLinkUtils + + private val mockWebServer = MockWebServer() + private val baseUrl = "/shl/" + + private val shlRetrofitService by lazy { + RetrofitSHLService.Builder(mockWebServer.url(baseUrl).toString(), NetworkConfiguration()) + .build() + } + + private val manifestFileResponseWithEmbedded = + JSONObject().apply { + val filesArray = + JSONArray().apply { + val fileObject = JSONObject().apply { put("embedded", "embeddedData") } + put(fileObject) + } + put("files", filesArray) + } + + private val manifestFileResponseWithLocation = + JSONObject().apply { + val filesArray = + JSONArray().apply { + val fileObject = JSONObject().apply { put("location", "locationData") } + put(fileObject) + } + put("files", filesArray) + } + + private val getLocationResponse = "locationData" + private val exampleSHL = + "shlink:/eyJsYWJlbCI6IkN1c3RvbSBEYXRhc2V0IDExIiwidXJsIjoiaHR0cHM6Ly9hcGkudmF4eC5saW5rL2FwaS9zaGwveFJ4M2Q0QzNROE0wbnhaejZmc1ZYdGYyLW5QTDlwUXdBb2RRZVVYNzFqYyIsImZsYWciOiIiLCJrZXkiOiI0RXNvSFF3WXdTLU8wVW43WkNBQXlSMnQ2TVJ3WjJpSndLM0hhY2hpbmcifQ==" + private val filesWithEmbedded = + "{\"files\": [{\"contentType\": \"application/smart-health-card\", \"embedded\": \"embeddedData\"}]}" + private val filesWithLocation = + "{\"files\": [{\"contentType\": \"application/smart-health-card\", \"location\": \"https://api.vaxx.link/api/shl/xRx3d4C3Q8M0nxZz6fsVXtf2-nPL9pQwAodQeUX71jc/file/EGxABJF-Co4oplPtLN87HpSlydj9K_BhCip1sGUvevY?ticket=...\"}]}" + private val testBundleString = + """ + { + "resourceType": "Bundle", + "id": "bundle-example" + } + """ + .trimIndent() + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + shLinkDecoderImpl = SHLinkDecoderImpl(readSHLinkUtils, shlRetrofitService) + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + @Test + fun `test decodeSHLinkToDocument with embedded data and no verifiable content`() = runBlocking { + val mockResponse = MockResponse().setResponseCode(200).setBody(filesWithEmbedded) + mockWebServer.enqueue(mockResponse) + + `when`(readSHLinkUtils.extractUrl("fullLink")).thenReturn("extractedJson") + `when`(readSHLinkUtils.decodeUrl("extractedJson")).thenReturn("{}".toByteArray()) + `when`(readSHLinkUtils.decodeShc(anyString(), anyString())).thenReturn(testBundleString) + `when`(readSHLinkUtils.extractVerifiableCredential(testBundleString)).thenReturn("") + + val result = + shLinkDecoderImpl.decodeSHLinkToDocument( + exampleSHL, + "", + "", + ) + + assertThat(result?.document?.id).isEqualTo("Bundle/bundle-example") + } + + @Test + fun `test decodeSHLinkToDocument with externally stored data and no verifiable content`() = + runBlocking { + val mockResponse = MockResponse().setResponseCode(200).setBody(filesWithLocation) + mockWebServer.enqueue(mockResponse) + + val mockGetLocationResponse = MockResponse().setResponseCode(200).setBody(getLocationResponse) + mockWebServer.enqueue(mockGetLocationResponse) + + `when`(readSHLinkUtils.extractUrl("fullLink")).thenReturn("extractedJson") + `when`(readSHLinkUtils.decodeUrl("extractedJson")).thenReturn("{}".toByteArray()) + `when`(readSHLinkUtils.decodeShc(anyString(), anyString())).thenReturn(testBundleString) + `when`(readSHLinkUtils.extractVerifiableCredential(testBundleString)).thenReturn("") + + val result = + shLinkDecoderImpl.decodeSHLinkToDocument( + exampleSHL, + "", + "", + ) + + assertThat(result!!.document).isNotNull() + + val recordedRequestGetManifest: RecordedRequest = mockWebServer.takeRequest() + assertThat(recordedRequestGetManifest.path) + .isEqualTo("/shl/xRx3d4C3Q8M0nxZz6fsVXtf2-nPL9pQwAodQeUX71jc") + + val recordedRequestGetLocation: RecordedRequest = mockWebServer.takeRequest() + assertThat( + recordedRequestGetLocation.path!!, + ) + .contains( + "/shl/file/EGxABJF-Co4oplPtLN87HpSlydj9K_BhCip1sGUvevY?ticket=", + ) + } + + @Test + fun `test decodeSHLinkToDocument with no data stored at the external location provided`() { + val mockResponse = + MockResponse() + .setResponseCode(200) + .setBody( + filesWithLocation, + ) + mockWebServer.enqueue(mockResponse) + + val mockGetLocationResponse = MockResponse().setResponseCode(200).setBody("") + mockWebServer.enqueue(mockGetLocationResponse) + + `when`(readSHLinkUtils.extractUrl("fullLink")).thenReturn("extractedJson") + `when`(readSHLinkUtils.decodeUrl("extractedJson")).thenReturn("{}".toByteArray()) + `when`(readSHLinkUtils.decodeShc(anyString(), anyString())).thenReturn(testBundleString) + `when`(readSHLinkUtils.extractVerifiableCredential(testBundleString)).thenReturn("") + + val error = + assertThrows(Error::class.java) { + runBlocking { + shLinkDecoderImpl.decodeSHLinkToDocument( + exampleSHL, + "", + "", + ) + } + } + assertThat(error.message).contains("No data found at the given location") + } + + @Test + fun `test decodeSHLinkToDocument with an invalid SHL passed in`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + shLinkDecoderImpl.decodeSHLinkToDocument( + "invalidLink", + "", + "", + ) + } + } + assertThat(exception.message).isEqualTo("Not a valid SHLink") + } + + @Test + fun `test decodeSHLinkToDocument with an empty SHL passed in`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + shLinkDecoderImpl.decodeSHLinkToDocument( + "", + "", + "", + ) + } + } + assertThat(exception.message).isEqualTo("Not a valid SHLink") + } + + @Test + fun `test decodeSHLinkToDocument with an unsuccessful POST to the manifest URL`() { + val mockResponse = + MockResponse().setResponseCode(500).setBody(manifestFileResponseWithLocation.toString()) + mockWebServer.enqueue(mockResponse) + + `when`(readSHLinkUtils.extractUrl("fullLink")).thenReturn("extractedJson") + `when`(readSHLinkUtils.decodeUrl("extractedJson")).thenReturn("{}".toByteArray()) + + val exception = + assertThrows(Error::class.java) { + runBlocking { + shLinkDecoderImpl.decodeSHLinkToDocument( + exampleSHL, + "", + "", + ) + } + } + assertThat(exception.message).contains("Error posting to the manifest:") + } + + @Test + fun `test decodeSHLinkToDocument with embedded data and verifiable content`() = runBlocking { + val mockResponse = + MockResponse().setResponseCode(200).setBody(manifestFileResponseWithEmbedded.toString()) + mockWebServer.enqueue(mockResponse) + + `when`(readSHLinkUtils.extractUrl("fullLink")).thenReturn("extractedJson") + `when`(readSHLinkUtils.decodeUrl("extractedJson")).thenReturn("{}".toByteArray()) + `when`(readSHLinkUtils.decodeShc(anyString(), anyString())).thenReturn(testBundleString) + `when`(readSHLinkUtils.extractVerifiableCredential(testBundleString)) + .thenReturn("verifiableCredentialData") + `when`(readSHLinkUtils.decodeAndDecompressPayload("verifiableCredentialData")) + .thenReturn( + "{\"vc\": {\"credentialSubject\":{\"fhirBundle\":{\"resourceType\":\"Bundle\"}}}}", + ) + + val result = + shLinkDecoderImpl.decodeSHLinkToDocument( + exampleSHL, + "", + "", + ) + + assertThat(result!!.document).isNotNull() + + val recordedRequestGetManifest: RecordedRequest = mockWebServer.takeRequest() + assertThat(recordedRequestGetManifest.path) + .isEqualTo( + "/shl/xRx3d4C3Q8M0nxZz6fsVXtf2-nPL9pQwAodQeUX71jc", + ) + } +} From 904b22132fa8d039f51c8cb3283b2a52dac091d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 17:01:33 +0100 Subject: [PATCH 04/10] Bump com.squareup:kotlinpoet from 1.15.3 to 1.17.0 (#2572) Bumps [com.squareup:kotlinpoet](https://github.com/square/kotlinpoet) from 1.15.3 to 1.17.0. - [Release notes](https://github.com/square/kotlinpoet/releases) - [Changelog](https://github.com/square/kotlinpoet/blob/main/docs/changelog.md) - [Commits](https://github.com/square/kotlinpoet/compare/1.15.3...1.17.0) --- updated-dependencies: - dependency-name: com.squareup:kotlinpoet dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 4dec42c493..0f48d57a24 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -19,5 +19,5 @@ dependencies { implementation("com.spotify.ruler:ruler-gradle-plugin:1.4.0") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.10.0") - implementation("com.squareup:kotlinpoet:1.15.3") + implementation("com.squareup:kotlinpoet:1.17.0") } From ae8c9bb5d2b29f230eade735de6b6ec000daa1d0 Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Tue, 18 Jun 2024 22:36:01 +0530 Subject: [PATCH 05/10] Handle empty repeated group header. (#2574) * empty repeated group header. * Remove unnecessary filter --------- Co-authored-by: Santosh Pingle <spingle@google.com> Co-authored-by: Jing Tang <jingtang@google.com> --- .../android/fhir/datacapture/QuestionnaireViewModel.kt | 8 +++----- .../extensions/MoreQuestionnaireItemComponents.kt | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) 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 da68320b85..4d8f44a33a 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 @@ -154,11 +154,9 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } // Retain the hierarchy and order of items within the questionnaire as specified in the // standard. See https://www.hl7.org/fhir/questionnaireresponse.html#notes. - questionnaire.item.forEach { - if (it.type != Questionnaire.QuestionnaireItemType.GROUP || !it.repeats) { - questionnaireResponse.addItem(it.createQuestionnaireResponseItem()) - } - } + questionnaire.item + .filterNot { it.isRepeatedGroup } + .forEach { questionnaireResponse.addItem(it.createQuestionnaireResponseItem()) } } } questionnaireResponse.packRepeatedGroups(questionnaire) 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 5d73a71537..c7b6b97ba5 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 @@ -915,7 +915,9 @@ fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItem(): !repeats ) { this@createQuestionnaireResponseItem.item.forEach { - this.addItem(it.createQuestionnaireResponseItem()) + if (!it.isRepeatedGroup) { + this.addItem(it.createQuestionnaireResponseItem()) + } } } } From bf83f8511df6af6d82e333e4cfb3ff162f4ca4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Deniger=20-=20ICRC?= <65169434+icrc-fdeniger@users.noreply.github.com> Date: Wed, 19 Jun 2024 14:37:37 +0200 Subject: [PATCH 06/10] #2503 push SNAPSHOT Maven artifacts to github Packages (#2525) * push artifacts to github Packages * push artifacts to github Packages * try to use github.run_id * try to use github.run_id * modify comments as suggests by MJ1998 * revert changes as required * revert changes as required * revert changes as required * revert changes as required * Format and language changes in the documentation --------- Co-authored-by: Madhuram Jajoo <madhuramjajoo10@gmail.com> Co-authored-by: Jing Tang <jingtang@google.com> --- .github/workflows/build.yml | 15 +++++- buildSrc/src/main/kotlin/Releases.kt | 19 +++++-- docs/use/Snapshots.md | 76 ++++++++++++++++++++++++++++ mkdocs.yaml | 1 + 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 docs/use/Snapshots.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f87a25b257..132b0fe915 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,7 @@ jobs: permissions: actions: read contents: read + packages: write strategy: fail-fast: false @@ -75,9 +76,21 @@ jobs: - name: Check with Gradle run: ./gradlew check --scan --full-stacktrace + - name: Publish Maven packages to GitHub Packages + if: ${{ github.event_name == 'push' }} + run: ./gradlew publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY_URL: 'https://maven.pkg.github.com/google/android-fhir' + # Use SNAPSHOT Prefix to follow Maven convention + ARTIFACT_VERSION_SUFFIX: SNAPSHOT + - name: Release artifacts to local repo run: ./gradlew publishReleasePublicationToCIRepository --scan - - name: Upload maven repo + env: + ARTIFACT_VERSION_SUFFIX: build_${{ github.run_id }} + + - name: Upload artifact maven-repository.zip uses: actions/upload-artifact@v4 with: name: maven-repository diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index 69ed05d2de..89e5b6c34d 100644 --- a/buildSrc/src/main/kotlin/Releases.kt +++ b/buildSrc/src/main/kotlin/Releases.kt @@ -119,20 +119,33 @@ fun Project.publishArtifact(artifact: LibraryArtifact) { licenses { license { name.set("The Apache License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") } } } repositories { maven { name = "CI" - url = uri("file://${rootProject.buildDir}/ci-repo") + url = + if (System.getenv("REPOSITORY_URL") != null) { + // REPOSITORY_URL is defined in .github/workflows/build.yml + uri(System.getenv("REPOSITORY_URL")) + } else { + uri("file://${rootProject.buildDir}/ci-repo") + } version = if (project.providers.environmentVariable("GITHUB_ACTIONS").isPresent) { - "${artifact.version}-build_${System.getenv("GITHUB_RUN_ID")}" + // ARTIFACT_VERSION_SUFFIX is defined in .github/workflows/build.yml + "${artifact.version}-${System.getenv("ARTIFACT_VERSION_SUFFIX")}" } else { artifact.version } + if (System.getenv("GITHUB_TOKEN") != null) { + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } } } } diff --git a/docs/use/Snapshots.md b/docs/use/Snapshots.md new file mode 100644 index 0000000000..583c5694d1 --- /dev/null +++ b/docs/use/Snapshots.md @@ -0,0 +1,76 @@ +# Snapshots + +You can test the latest Android FHIR SDK libraries using the snapshot versions published on GitHub Packages. + +They are unreleased versions of the library built from the `HEAD` of the main branch and have the `-SNAPSHOT` suffix in their version numbers. + +They can be found here: https://github.com/google?tab=packages&repo_name=android-fhir + +> :warning: The snapshots are for testing and development purposes only. They are not QA tested and not production ready. Do **NOT** use them in production. + +# How to use SNAPSHOT artifacts + +## Configure GitHub maven repositories in `build.gradle.kts` + +Since these artifacts are deployed on GitHub Packages, a `username`/`GitHub token` pair is required as explained in [Authenticating to GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry#authenticating-to-github-packages). The token needs at least the `read:packages` scope. + +This can be securely managed by placing the credentials in the `local.properties` file and loading them with `gradleLocalProperties`. With this approach, the file `build.gradle.kts` will look like: + +```kotlin +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + ... +} + +android { + ... + repositories{ + maven { + url = uri("https://maven.pkg.github.com/google/android-fhir") + credentials { + username = gradleLocalProperties(rootDir).getProperty("gpr.user") ?: System.getenv("GPR_USER") + password = gradleLocalProperties(rootDir).getProperty("gpr.key") ?: System.getenv("GPR_KEY") + } + } + } +} + +dependencies { +} +``` + +Notice the environment variables `GPR_USER`/`GPR_KEY` used in this file. + +Then, the file `local.properties` will need to be created in the project root folder: + +```dotenv +sdk.dir=<path to Android SDK> +gpr.user=<Your GitHub Account> +gpr.key=<A GitHub token> +``` + +## Declare dependencies + +To include the snapshots in the dependencies of your app, modify `build.gradle.kts` in your app: + +```kotlin +dependencies { + ... + implementation("com.google.android.fhir:engine:<engine-version>-SNAPSHOT") + implementation("com.google.android.fhir:data-capture:<dc-version>-SNAPSHOT") +} +``` + +The versions `<...-version>` can be found in https://github.com/google?tab=packages&repo_name=android-fhir + +## How SNAPSHOT versions are managed by Gradle + +The complete documentation can be found in the section [Declaring a changing version](https://docs.gradle.org/current/userguide/dynamic_versions.html#sub:declaring_dependency_with_changing_version). + +To summarize: +- By default, Gradle caches changing versions of dependencies for **24 hours** +- Dependency caching can be [controlled programmatically](https://docs.gradle.org/current/userguide/dynamic_versions.html#sec:controlling_dependency_caching_programmatically) +- The `--refresh-dependencies` option in command line tells Gradle to ignore all cached versions + + diff --git a/mkdocs.yaml b/mkdocs.yaml index ea6ca45351..0ecd127b5a 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -42,6 +42,7 @@ nav: - use/WFL/Compile-and-Execute-CQL.md - use/Extensions.md - API Doc: use/api.md + - Use Snapshots: use/Snapshots.md - Contributors: - Contributing: contrib/index.md - Codespaces: contrib/codespaces.md From 541f69d8506845a73c65d4569d95aa586af07f3b Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Wed, 19 Jun 2024 21:37:39 +0530 Subject: [PATCH 07/10] repeated group style. (#2571) * repeated group style. * rename string variable name * Address review comments. --------- Co-authored-by: Santosh Pingle <spingle@google.com> --- .../datacapture/QuestionnaireAdapterItem.kt | 1 + .../datacapture/QuestionnaireEditAdapter.kt | 15 ++++---- .../datacapture/QuestionnaireViewModel.kt | 1 + .../views/factories/GroupViewHolderFactory.kt | 5 +++ .../QuestionnaireItemViewHolderFactory.kt | 26 ++++++++++++++ .../src/main/res/drawable/add_24px.xml | 13 +++++++ .../src/main/res/drawable/delete_24px.xml | 13 +++++++ .../src/main/res/layout/group_header_view.xml | 3 +- .../repeated_group_instance_header_view.xml | 7 ++-- datacapture/src/main/res/values/attrs.xml | 8 +++++ datacapture/src/main/res/values/strings.xml | 2 ++ datacapture/src/main/res/values/styles.xml | 34 +++++++++++++++++++ 12 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 datacapture/src/main/res/drawable/add_24px.xml create mode 100644 datacapture/src/main/res/drawable/delete_24px.xml diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt index e725f7a136..d67430f6bf 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt @@ -32,5 +32,6 @@ internal sealed interface QuestionnaireAdapterItem { val onDeleteClicked: () -> Unit, /** Responses nested under this header. */ val responses: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>, + val title: String, ) : QuestionnaireAdapterItem } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt index 17319760ab..e7c53ac2b1 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt @@ -18,7 +18,6 @@ package com.google.android.fhir.datacapture import android.view.View import android.view.ViewGroup -import android.widget.TextView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView @@ -43,6 +42,7 @@ import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFac import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.android.fhir.datacapture.views.factories.RadioGroupViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder import com.google.android.fhir.datacapture.views.factories.SliderViewHolderFactory import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemType @@ -64,7 +64,9 @@ internal class QuestionnaireEditAdapter( ViewHolder.QuestionHolder(onCreateViewHolderQuestion(parent = parent, subtype = subtype)) ViewType.Type.REPEATED_GROUP_HEADER -> { ViewHolder.RepeatedGroupHeaderHolder( - parent.inflate(R.layout.repeated_group_instance_header_view), + RepeatedGroupHeaderItemViewHolder( + parent.inflate(R.layout.repeated_group_instance_header_view), + ), ) } } @@ -118,8 +120,7 @@ internal class QuestionnaireEditAdapter( } is QuestionnaireAdapterItem.RepeatedGroupHeader -> { holder as ViewHolder.RepeatedGroupHeaderHolder - holder.header.text = "Group ${item.index + 1}" - holder.delete.setOnClickListener { item.onDeleteClicked() } + holder.viewHolder.bind(item) } } } @@ -266,10 +267,8 @@ internal class QuestionnaireEditAdapter( internal sealed class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { class QuestionHolder(val holder: QuestionnaireItemViewHolder) : ViewHolder(holder.itemView) - class RepeatedGroupHeaderHolder(itemView: View) : ViewHolder(itemView) { - val header: TextView = itemView.findViewById(R.id.repeated_group_instance_header_title) - val delete: View = itemView.findViewById(R.id.repeated_group_instance_header_delete_button) - } + class RepeatedGroupHeaderHolder(val viewHolder: RepeatedGroupHeaderItemViewHolder) : + ViewHolder(viewHolder.itemView) } internal companion object { 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 4d8f44a33a..475881ae2f 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 @@ -845,6 +845,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat index = index, onDeleteClicked = { viewModelScope.launch { question.item.removeAnswerAt(index) } }, responses = nestedResponseItemList, + title = question.item.questionText?.toString() ?: "", ), ) } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/GroupViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/GroupViewHolderFactory.kt index 844ead25d4..a65b5a2d1c 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/GroupViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/GroupViewHolderFactory.kt @@ -52,6 +52,11 @@ internal object GroupViewHolderFactory : override fun bind(questionnaireViewItem: QuestionnaireViewItem) { header.bind(questionnaireViewItem) + addItemButton.text = + context.getString( + R.string.add_repeated_group_item, + questionnaireViewItem.questionText ?: "", + ) addItemButton.visibility = if (questionnaireViewItem.questionnaireItem.repeats) View.VISIBLE else View.GONE addItemButton.setOnClickListener { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuestionnaireItemViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuestionnaireItemViewHolderFactory.kt index f7f26acec7..debc57b738 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuestionnaireItemViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuestionnaireItemViewHolderFactory.kt @@ -19,8 +19,10 @@ package com.google.android.fhir.datacapture.views.factories import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView import androidx.annotation.LayoutRes import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.datacapture.QuestionnaireAdapterItem import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.views.MediaView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem @@ -70,6 +72,30 @@ class QuestionnaireItemViewHolder( } } +/** The [RecyclerView.ViewHolder] for [QuestionnaireAdapterItem.RepeatedGroupHeader]. */ +internal class RepeatedGroupHeaderItemViewHolder( + itemView: View, +) : RecyclerView.ViewHolder(itemView) { + + private val header: TextView + private val delete: View + + init { + header = itemView.findViewById(R.id.repeated_group_instance_header_title) + delete = itemView.findViewById(R.id.repeated_group_instance_header_delete_button) + } + + fun bind(repeatedGroupHeader: QuestionnaireAdapterItem.RepeatedGroupHeader) { + header.text = + header.context.getString( + R.string.repeated_group_title, + "${repeatedGroupHeader.index + 1}", + repeatedGroupHeader.title, + ) + delete.setOnClickListener { repeatedGroupHeader.onDeleteClicked() } + } +} + /** * Delegate for [QuestionnaireItemViewHolder]. * diff --git a/datacapture/src/main/res/drawable/add_24px.xml b/datacapture/src/main/res/drawable/add_24px.xml new file mode 100644 index 0000000000..697ab3fcc0 --- /dev/null +++ b/datacapture/src/main/res/drawable/add_24px.xml @@ -0,0 +1,13 @@ +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" +> + <path + android:fillColor="@android:color/white" + android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" + /> +</vector> diff --git a/datacapture/src/main/res/drawable/delete_24px.xml b/datacapture/src/main/res/drawable/delete_24px.xml new file mode 100644 index 0000000000..287ae418fb --- /dev/null +++ b/datacapture/src/main/res/drawable/delete_24px.xml @@ -0,0 +1,13 @@ +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" +> + <path + android:fillColor="@android:color/white" + android:pathData="M280,520L680,520L680,440L280,440L280,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" + /> +</vector> diff --git a/datacapture/src/main/res/layout/group_header_view.xml b/datacapture/src/main/res/layout/group_header_view.xml index def51fbde1..f8e58a23c0 100644 --- a/datacapture/src/main/res/layout/group_header_view.xml +++ b/datacapture/src/main/res/layout/group_header_view.xml @@ -52,11 +52,10 @@ <com.google.android.material.button.MaterialButton android:id="@+id/add_item" + style="?attr/questionnaireAddRepeatedGroupButtonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" - android:orientation="vertical" - android:text="@string/group_header_add_item_button" /> </LinearLayout> diff --git a/datacapture/src/main/res/layout/repeated_group_instance_header_view.xml b/datacapture/src/main/res/layout/repeated_group_instance_header_view.xml index b2357d3430..870ce7e710 100644 --- a/datacapture/src/main/res/layout/repeated_group_instance_header_view.xml +++ b/datacapture/src/main/res/layout/repeated_group_instance_header_view.xml @@ -30,11 +30,12 @@ android:layout_height="wrap_content" android:layout_weight="1" /> - <ImageView + <com.google.android.material.button.MaterialButton android:id="@+id/repeated_group_instance_header_delete_button" + style="?attr/questionnaireDeleteRepeatedGroupButtonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:padding="@dimen/padding_between_text_and_icon" - android:src="@drawable/ic_delete" + android:layout_gravity="center_horizontal" + android:text="@string/delete" /> </LinearLayout> diff --git a/datacapture/src/main/res/values/attrs.xml b/datacapture/src/main/res/values/attrs.xml index f0b567b8c2..46d076ed59 100644 --- a/datacapture/src/main/res/values/attrs.xml +++ b/datacapture/src/main/res/values/attrs.xml @@ -127,6 +127,14 @@ <!-- Style for edit button. --> <attr name="questionnaireEditButtonStyle" format="reference" /> + <!-- Style for Add another repeated group item button. --> + <attr name="questionnaireAddRepeatedGroupButtonStyle" format="reference" /> + <!-- Style for Delete repeated group item button. --> + <attr + name="questionnaireDeleteRepeatedGroupButtonStyle" + format="reference" + /> + <!-- Style for validation error dialog title text in modal view. --> <attr name="questionnaireValidationDialogTitleStyle" format="reference" /> diff --git a/datacapture/src/main/res/values/strings.xml b/datacapture/src/main/res/values/strings.xml index 72b5ccd08b..79fa579348 100644 --- a/datacapture/src/main/res/values/strings.xml +++ b/datacapture/src/main/res/values/strings.xml @@ -137,4 +137,6 @@ <string name="required">Required</string> <string name="required_text_and_new_line">Required\n</string> <string name="space_asterisk">\u0020\u002a</string> + <string name="repeated_group_title">%1$s. %2$s</string> + <string name="add_repeated_group_item">Add %1$s</string> </resources> diff --git a/datacapture/src/main/res/values/styles.xml b/datacapture/src/main/res/values/styles.xml index 59557b9ecd..d78007c0dd 100644 --- a/datacapture/src/main/res/values/styles.xml +++ b/datacapture/src/main/res/values/styles.xml @@ -133,6 +133,14 @@ name="questionnaireEditButtonStyle" >@style/Questionnaire.EditButtonStyle </item> + <item + name="questionnaireAddRepeatedGroupButtonStyle" + >@style/Questionnaire.AddRepeatedGroupButtonStyle + </item> + <item + name="questionnaireDeleteRepeatedGroupButtonStyle" + >@style/Questionnaire.DeleteRepeatedGroupButtonStyle + </item> <item name="questionnaireDialogButtonStyle"> @style/Widget.Material3.Button.TextButton.Dialog </item> @@ -385,6 +393,32 @@ <item name="android:textAllCaps">false</item> </style> + <style + name="Questionnaire.AddRepeatedGroupButtonStyle" + parent="Widget.Material3.Button.OutlinedButton" + > + <item name="android:textAllCaps">false</item> + <item name="cornerRadius">4dp</item> + <item name="icon">@drawable/add_24px</item> + <item + name="strokeColor" + >@color/m3_text_button_foreground_color_selector</item> + <item name="android:paddingLeft">@dimen/m3_btn_text_btn_padding_left</item> + </style> + + <style + name="Questionnaire.DeleteRepeatedGroupButtonStyle" + parent="Widget.Material3.Button.OutlinedButton" + > + <item name="android:textAllCaps">false</item> + <item name="android:textColor">@color/mtrl_error</item> + <item name="cornerRadius">4dp</item> + <item name="icon">@drawable/delete_24px</item> + <item name="strokeColor">@color/mtrl_error</item> + <item name="android:paddingLeft">@dimen/m3_btn_text_btn_padding_left</item> + <item name="iconTint">@color/mtrl_error</item> + </style> + <style name="Questionnaire.SubmitButtonStyle" parent="Widget.Material3.Button" From ced8527a5481972591615ad4364487e89130fb6e Mon Sep 17 00:00:00 2001 From: Madhuram Jajoo <madhuramjajoo10@gmail.com> Date: Thu, 20 Jun 2024 14:40:52 +0530 Subject: [PATCH 08/10] Fix cursor moving back and missing characters (#2537) * Avoid EditText#setText to mitigate TextWatcher racing condition * Update edit text only when new question comes into the view * use focusable views * remove debug statement * Separate validation update from input-text-box update * remove unnecessary changes * fix build errors * kdocs * add unit test --- .../views/PhoneNumberViewHolderFactory.kt | 16 +- .../EditTextDecimalViewHolderFactory.kt | 20 +- .../EditTextIntegerViewHolderFactory.kt | 19 +- .../EditTextStringViewHolderDelegate.kt | 19 +- .../factories/EditTextViewHolderFactory.kt | 67 ++++--- .../EditTextViewHolderFactoryTest.kt | 174 ++++++++++++++++++ 6 files changed, 280 insertions(+), 35 deletions(-) create mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactoryTest.kt diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactory.kt index cb58af847c..638ff2fe14 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactory.kt @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.contrib.views import android.text.Editable import android.text.InputType import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemEditTextViewHolderDelegate import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderDelegate @@ -58,10 +59,9 @@ object PhoneNumberViewHolderFactory : } } - override fun updateUI( + override fun updateInputTextUI( questionnaireViewItem: QuestionnaireViewItem, textInputEditText: TextInputEditText, - textInputLayout: TextInputLayout, ) { val text = questionnaireViewItem.answers.singleOrNull()?.valueStringType?.value?.toString() ?: "" @@ -69,5 +69,17 @@ object PhoneNumberViewHolderFactory : textInputEditText.setText(text) } } + + override fun updateValidationTextUI( + questionnaireViewItem: QuestionnaireViewItem, + textInputLayout: TextInputLayout, + ) { + textInputLayout.error = + getValidationErrorMessage( + textInputLayout.context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) + } } } 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 58bde90873..7f0ef4a7d8 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 @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.views.factories import android.text.Editable import android.text.InputType import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout @@ -43,10 +44,9 @@ internal object EditTextDecimalViewHolderFactory : ?: questionnaireViewItem.setDraftAnswer(editable.toString()) } - override fun updateUI( + override fun updateInputTextUI( questionnaireViewItem: QuestionnaireViewItem, textInputEditText: TextInputEditText, - textInputLayout: TextInputLayout, ) { val questionnaireItemViewItemDecimalAnswer = questionnaireViewItem.answers.singleOrNull()?.valueDecimalType?.value?.toString() @@ -62,10 +62,22 @@ internal object EditTextDecimalViewHolderFactory : } else if (draftAnswer != null && draftAnswer != textInputEditText.text.toString()) { textInputEditText.setText(draftAnswer) } + } + + override fun updateValidationTextUI( + questionnaireViewItem: QuestionnaireViewItem, + textInputLayout: TextInputLayout, + ) { + textInputLayout.error = + getValidationErrorMessage( + textInputLayout.context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) // Update error message if draft answer present - if (draftAnswer != null) { + if (questionnaireViewItem.draftAnswer != null) { textInputLayout.error = - textInputEditText.context.getString(R.string.decimal_format_validation_error_msg) + textInputLayout.context.getString(R.string.decimal_format_validation_error_msg) } } } 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 dd0affd92e..f619802494 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 @@ -23,6 +23,7 @@ import android.text.Editable import android.text.InputType import androidx.annotation.RequiresApi import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout @@ -58,10 +59,9 @@ internal object EditTextIntegerViewHolderFactory : } } - override fun updateUI( + override fun updateInputTextUI( questionnaireViewItem: QuestionnaireViewItem, textInputEditText: TextInputEditText, - textInputLayout: TextInputLayout, ) { val answer = questionnaireViewItem.answers.singleOrNull()?.valueIntegerType?.value?.toString() @@ -78,11 +78,22 @@ internal object EditTextIntegerViewHolderFactory : } else if (draftAnswer != null && draftAnswer != textInputEditText.text.toString()) { textInputEditText.setText(draftAnswer) } + } + override fun updateValidationTextUI( + questionnaireViewItem: QuestionnaireViewItem, + textInputLayout: TextInputLayout, + ) { + textInputLayout.error = + getValidationErrorMessage( + textInputLayout.context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) // Update error message if draft answer present - if (draftAnswer != null) { + if (questionnaireViewItem.draftAnswer != null) { textInputLayout.error = - textInputEditText.context.getString( + textInputLayout.context.getString( R.string.integer_format_validation_error_msg, formatInteger(Int.MIN_VALUE), formatInteger(Int.MAX_VALUE), diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextStringViewHolderDelegate.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextStringViewHolderDelegate.kt index dc6a3eef33..cd9d284ec0 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextStringViewHolderDelegate.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextStringViewHolderDelegate.kt @@ -18,6 +18,7 @@ package com.google.android.fhir.datacapture.views.factories import android.text.Editable import android.text.InputType +import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout @@ -58,14 +59,26 @@ internal class EditTextStringViewHolderDelegate : } } - override fun updateUI( + override fun updateInputTextUI( questionnaireViewItem: QuestionnaireViewItem, textInputEditText: TextInputEditText, - textInputLayout: TextInputLayout, ) { val text = questionnaireViewItem.answers.singleOrNull()?.valueStringType?.value ?: "" if ((text != textInputEditText.text.toString())) { - textInputEditText.setText(text) + textInputEditText.text?.clear() + textInputEditText.append(text) } } + + override fun updateValidationTextUI( + questionnaireViewItem: QuestionnaireViewItem, + textInputLayout: TextInputLayout, + ) { + textInputLayout.error = + getValidationErrorMessage( + textInputLayout.context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) + } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt index c83487b28c..44fb753521 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt @@ -32,11 +32,9 @@ import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText -import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.extensions.unit -import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.HeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.material.textfield.TextInputEditText @@ -55,7 +53,7 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT private lateinit var context: AppCompatActivity private lateinit var header: HeaderView - protected lateinit var textInputLayout: TextInputLayout + private lateinit var textInputLayout: TextInputLayout private lateinit var textInputEditText: TextInputEditText private var unitTextView: TextView? = null private var textWatcher: TextWatcher? = null @@ -74,7 +72,7 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT // https://stackoverflow.com/questions/13614101/fatal-crash-focus-search-returned-a-view-that-wasnt-able-to-take-focus/47991577 textInputEditText.setOnEditorActionListener { view, actionId, _ -> if (actionId != EditorInfo.IME_ACTION_NEXT) { - false + return@setOnEditorActionListener false } view.focusSearch(FOCUS_DOWN)?.requestFocus(FOCUS_DOWN) ?: false } @@ -99,25 +97,45 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverSpanned helperText = getRequiredOrOptionalText(questionnaireViewItem, context) } - displayValidationResult(questionnaireViewItem.validationResult) - textInputEditText.removeTextChangedListener(textWatcher) - updateUI(questionnaireViewItem, textInputEditText, textInputLayout) - - unitTextView?.apply { - text = questionnaireViewItem.questionnaireItem.unit?.code - visibility = if (text.isNullOrEmpty()) GONE else VISIBLE - } - - textWatcher = - textInputEditText.doAfterTextChanged { editable: Editable? -> - context.lifecycleScope.launch { handleInput(editable!!, questionnaireViewItem) } + /** + * Ensures that any validation errors or warnings are immediately reflected in the UI whenever + * the view is bound to a new or updated item. + */ + updateValidationTextUI(questionnaireViewItem, textInputLayout) + + /** + * Updates the EditText *only* if the EditText is not currently focused. + * + * This is done to avoid disrupting the user's typing experience and prevent conflicts if they + * are actively editing the field. Updating the text programmatically is safe in the following + * scenarios: + * 1. **ViewHolder Reuse:** When the same ViewHolder is being used to display a different + * QuestionnaireViewItem, the EditText needs to be updated with the new item's content. + * 2. **Read-Only Items:** When the item is read-only, its value may change dynamically due to + * expressions, and the EditText needs to reflect this updated value. + * + * The following actions are performed if the EditText is not focused: + * - Removes any existing text change listener. + * - Updates the input text UI based on the QuestionnaireViewItem. + * - Updates the unit text view (if applicable). + * - Attaches a new text change listener to handle user input. + */ + if (!textInputEditText.isFocused) { + textInputEditText.removeTextChangedListener(textWatcher) + updateInputTextUI(questionnaireViewItem, textInputEditText) + + unitTextView?.apply { + text = questionnaireViewItem.questionnaireItem.unit?.code + visibility = if (text.isNullOrEmpty()) GONE else VISIBLE } - } - private fun displayValidationResult(validationResult: ValidationResult) { - textInputLayout.error = - getValidationErrorMessage(textInputLayout.context, questionnaireViewItem, validationResult) + // TextWatcher is set only once for each question item in scenario 1 + textWatcher = + textInputEditText.doAfterTextChanged { editable: Editable? -> + context.lifecycleScope.launch { handleInput(editable!!, questionnaireViewItem) } + } + } } override fun setReadOnly(isReadOnly: Boolean) { @@ -128,10 +146,15 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT /** Handles user input from the `editable` and updates the questionnaire. */ abstract suspend fun handleInput(editable: Editable, questionnaireViewItem: QuestionnaireViewItem) - /** Handles the UI update. */ - abstract fun updateUI( + /** Handles the update of [textInputEditText].text. */ + abstract fun updateInputTextUI( questionnaireViewItem: QuestionnaireViewItem, textInputEditText: TextInputEditText, + ) + + /** Handles the update of [textInputLayout].error. */ + abstract fun updateValidationTextUI( + questionnaireViewItem: QuestionnaireViewItem, textInputLayout: TextInputLayout, ) } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactoryTest.kt new file mode 100644 index 0000000000..fbfaa8b38f --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactoryTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.factories + +import android.text.Editable +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity +import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage +import com.google.android.fhir.datacapture.validation.NotValidated +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.material.R +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.google.common.truth.Truth +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent +import org.hl7.fhir.r4.model.StringType +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EditTextViewHolderFactoryTest { + + private val parent = + FrameLayout( + Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { + setTheme(R.style.Theme_Material3_DayNight) + }, + ) + private val testViewHolder = + object : + EditTextViewHolderFactory( + com.google.android.fhir.datacapture.R.layout.edit_text_single_line_view, + ) { + override fun getQuestionnaireItemViewHolderDelegate() = + object : QuestionnaireItemEditTextViewHolderDelegate(DECIMAL_INPUT_TYPE) { + + private var programmaticUpdateCounter = 0 + + override suspend fun handleInput( + editable: Editable, + questionnaireViewItem: QuestionnaireViewItem, + ) {} + + override fun updateInputTextUI( + questionnaireViewItem: QuestionnaireViewItem, + textInputEditText: TextInputEditText, + ) { + programmaticUpdateCounter += 1 + textInputEditText.setText("$programmaticUpdateCounter") + } + + override fun updateValidationTextUI( + questionnaireViewItem: QuestionnaireViewItem, + textInputLayout: TextInputLayout, + ) { + textInputLayout.error = + getValidationErrorMessage( + textInputLayout.context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) + // Update error message if draft answer present + if (questionnaireViewItem.draftAnswer != null) { + textInputLayout.error = + textInputLayout.context.getString( + com.google.android.fhir.datacapture.R.string + .decimal_format_validation_error_msg, + ) + } + } + } + } + .create(parent) + + @Test + fun `binding when view is in focus does not programmatically update edit text but updates validation ui`() { + testViewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent(), + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + answer = + listOf( + QuestionnaireResponseItemAnswerComponent().apply { value = StringType("1") }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + testViewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent(), + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + answer = + listOf( + QuestionnaireResponseItemAnswerComponent().apply { value = StringType("1.1") }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + Truth.assertThat( + testViewHolder.itemView + .findViewById<TextInputEditText>( + com.google.android.fhir.datacapture.R.id.text_input_edit_text, + ) + .text + .toString(), + ) + .isEqualTo("2") // Value of [programmaticUpdateCounter] in the [testViewHolder] + + testViewHolder.itemView + .findViewById<TextInputEditText>( + com.google.android.fhir.datacapture.R.id.text_input_edit_text, + ) + .requestFocus() + + testViewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent(), + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { answer = emptyList() }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "1.1.", + ), + ) + + Truth.assertThat( + testViewHolder.itemView + .findViewById<TextInputEditText>( + com.google.android.fhir.datacapture.R.id.text_input_edit_text, + ) + .text + .toString(), + ) + .isEqualTo("2") // Since the view is in focus the value will not be updated + + Truth.assertThat( + testViewHolder.itemView + .findViewById<TextInputLayout>(com.google.android.fhir.datacapture.R.id.text_input_layout) + .error + .toString(), + ) + .isEqualTo( + testViewHolder.itemView + .findViewById<TextInputLayout>(com.google.android.fhir.datacapture.R.id.text_input_layout) + .context + .getString( + com.google.android.fhir.datacapture.R.string.decimal_format_validation_error_msg, + ), + ) + } +} From aea1af318b327716db3a1f6228105de5c74b78e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Jun 2024 13:57:39 +0100 Subject: [PATCH 09/10] Bump urllib3 from 2.2.1 to 2.2.2 in the pip group across 1 directory (#2573) Bumps the pip group with 1 update in the / directory: [urllib3](https://github.com/urllib3/urllib3). Updates `urllib3` from 2.2.1 to 2.2.2 - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.2.1...2.2.2) --- updated-dependencies: - dependency-name: urllib3 dependency-type: indirect dependency-group: pip ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Pipfile.lock | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 4095bfa7a0..b16ca7d8e3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -572,11 +572,12 @@ }, "urllib3": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", + "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" ], + "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==2.2.2" }, "watchdog": { "hashes": [ From 18d5e5386a7d3386d3627b28e733e854cffe3c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Deniger=20-=20ICRC?= <65169434+icrc-fdeniger@users.noreply.github.com> Date: Thu, 20 Jun 2024 19:41:28 +0200 Subject: [PATCH 10/10] update link to GitHub Packages (#2578) * update link to GitHub Packages * update link to GitHub Packages * Update docs/use/Snapshots.md Co-authored-by: Jing Tang <jingtang@google.com> --------- Co-authored-by: Jing Tang <jingtang@google.com> --- docs/use/Snapshots.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/use/Snapshots.md b/docs/use/Snapshots.md index 583c5694d1..bbb150f9eb 100644 --- a/docs/use/Snapshots.md +++ b/docs/use/Snapshots.md @@ -4,7 +4,7 @@ You can test the latest Android FHIR SDK libraries using the snapshot versions p They are unreleased versions of the library built from the `HEAD` of the main branch and have the `-SNAPSHOT` suffix in their version numbers. -They can be found here: https://github.com/google?tab=packages&repo_name=android-fhir +They can be found [here](https://github.com/orgs/google/packages?repo_name=android-fhir). > :warning: The snapshots are for testing and development purposes only. They are not QA tested and not production ready. Do **NOT** use them in production. @@ -69,8 +69,9 @@ The versions `<...-version>` can be found in https://github.com/google?tab=packa The complete documentation can be found in the section [Declaring a changing version](https://docs.gradle.org/current/userguide/dynamic_versions.html#sub:declaring_dependency_with_changing_version). To summarize: -- By default, Gradle caches changing versions of dependencies for **24 hours** -- Dependency caching can be [controlled programmatically](https://docs.gradle.org/current/userguide/dynamic_versions.html#sec:controlling_dependency_caching_programmatically) -- The `--refresh-dependencies` option in command line tells Gradle to ignore all cached versions + +* By default, Gradle caches changing versions of dependencies for **24 hours** +* Dependency caching can be [controlled programmatically](https://docs.gradle.org/current/userguide/dynamic_versions.html#sec:controlling_dependency_caching_programmatically) +* The `--refresh-dependencies` option in command line tells Gradle to ignore all cached versions