diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt index 86a2d13358..5da34c0be0 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt @@ -43,6 +43,7 @@ import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.ImplementationGuide import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.MetadataResource import org.hl7.fhir.r4.model.Resource @@ -397,7 +398,7 @@ constructor( sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)?.let { appId -> val parsedAppId = appId.substringBefore(TYPE_REFERENCE_DELIMITER).trim() val patientRelatedResourceTypes = mutableListOf() - val compositionResource = fetchRemoteComposition(parsedAppId) + val compositionResource = fetchRemoteCompositionByAppId(parsedAppId) compositionResource?.let { composition -> composition .retrieveCompositionSections() @@ -444,11 +445,43 @@ constructor( } } - suspend fun fetchRemoteComposition(appId: String?): Composition? { - Timber.i("Fetching configs for app $appId") + suspend fun fetchRemoteImplementationGuideByAppId( + appId: String?, + appVersionCode: Int?, + ): ImplementationGuide? { + Timber.i("Fetching ImplementationGuide config for app $appId version $appVersionCode") + val urlPath = - "${ResourceType.Composition.name}?${Composition.SP_IDENTIFIER}=$appId&_count=$DEFAULT_COUNT" + "ImplementationGuide?&name=$appId&context-quantity=le$appVersionCode&_sort=-context-quantity&_count=1" + return fhirResourceDataSource.getResource(urlPath).entryFirstRep.let { + if (!it.hasResource()) { + Timber.w("No response for ImplementationGuide resource on path $urlPath") + return null + } + + it.resource as ImplementationGuide + } + } + + suspend fun fetchRemoteCompositionById( + id: String?, + version: String?, + ): Composition? { + Timber.i("Fetching Composition config id $id version $version") + val urlPath = "Composition/$id/_history/$version" + return fhirResourceDataSource.getResource(urlPath).entryFirstRep.let { + if (!it.hasResource()) { + Timber.w("No response for composition resource on path $urlPath") + return null + } + + it.resource as Composition + } + } + suspend fun fetchRemoteCompositionByAppId(appId: String?): Composition? { + Timber.i("Fetching Composition config for app $appId") + val urlPath = "Composition?identifier=$appId&_count=$DEFAULT_COUNT" return fhirResourceDataSource.getResource(urlPath).entryFirstRep.let { if (!it.hasResource()) { Timber.w("No response for composition resource on path $urlPath") diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index bc913c92c6..f6f55fc3c5 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt @@ -44,6 +44,7 @@ import org.hl7.fhir.r4.model.Flag import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Immunization +import org.hl7.fhir.r4.model.ImplementationGuide import org.hl7.fhir.r4.model.Location import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient @@ -382,6 +383,14 @@ fun isValidResourceType(resourceCode: String): Boolean { } } +fun ImplementationGuide.retrieveImplementationGuideDefinitionResources(): + List { + val resources = + mutableListOf() + this.definition.resource.forEach { resources.add(it) } + return resources +} + /** * Composition sections can be nested. This function retrieves all the nested composition sections * and returns a flattened list of all [Composition.SectionComponent] for the given [Composition] diff --git a/android/engine/src/test/assets/configs/app/implementation_guide_config.json b/android/engine/src/test/assets/configs/app/implementation_guide_config.json new file mode 100644 index 0000000000..ccc3594dcf --- /dev/null +++ b/android/engine/src/test/assets/configs/app/implementation_guide_config.json @@ -0,0 +1,55 @@ +{ + "resourceType": "ImplementationGuide", + "id": "12967310", + "url": "https://fhir.zeir.smartregister.org/fhir/ImplementationGuide/quest", + "version": "1.1.0", + "name": "quest", + "title": "Quest Implementation Guide", + "status": "draft", + "packageId": "org.smartregister.fhircore", + "date": "2024-04-01", + "publisher": "Ona Systems, Inc.", + "fhirVersion": [ + { + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/FHIR-version", + "code": "4.3.0", + "display": "4.3.0" + } + ], + "text": "FHIR Release 4B." + } + } + ], + "useContext": { + "code": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/usage-context-type", + "code": "program", + "display": "Program" + } + ], + "text": "Program" + }, + "valueRange": { + "low": { + "value": 1 + }, + "high": { + "value": 10 + } + } + }, + "definition": { + "resource": [ + { + "reference": { + "reference": "Composition/8294" + } + } + ] + } +} diff --git a/android/gradle.properties b/android/gradle.properties index fa606f1f41..30b7024f0d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -22,4 +22,3 @@ android.defaults.buildfeatures.buildconfig=true android.suppressUnsupportedCompileSdk=34 android.jetifier.ignorelist=jackson-core org.gradle.warning.mode=all -android.suppressUnsupportedCompileSdk=34 diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt index c4fcb205f9..a3c22a8ca0 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt @@ -49,10 +49,13 @@ import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory import org.smartregister.fhircore.engine.util.extension.retrieveCompositionSections +import org.smartregister.fhircore.engine.util.extension.retrieveImplementationGuideDefinitionResources import org.smartregister.fhircore.quest.ui.login.LoginActivity import retrofit2.HttpException import timber.log.Timber +typealias QuestBuildConfig = org.smartregister.fhircore.quest.BuildConfig + @HiltViewModel class AppSettingViewModel @Inject @@ -102,10 +105,40 @@ constructor( viewModelScope.launch { try { showProgressBar.postValue(true) - Timber.i("Fetching configs for app $appId") - val compositionResource = - withContext(dispatcherProvider.io()) { - configurationRegistry.fetchRemoteComposition(appId) + + Timber.i( + "Fetching configs for app $appId with highest context-quantity ${QuestBuildConfig.VERSION_CODE}", + ) + + val compositionResource: Composition? + + val implementationGuideResource = + configurationRegistry.fetchRemoteImplementationGuideByAppId( + appId, + QuestBuildConfig.VERSION_CODE, + ) + + compositionResource = + if (implementationGuideResource != null) { + configurationRegistry.addOrUpdate(implementationGuideResource) + + val compositionReference = + implementationGuideResource + .retrieveImplementationGuideDefinitionResources()[0] + .reference + .reference + + val compositionIdWithHistory = compositionReference?.substringAfter('/') + val compositionId = compositionIdWithHistory?.substringBefore('/') + val compositionVersion = compositionIdWithHistory?.substringAfterLast('/', "") + + withContext(dispatcherProvider.io()) { + configurationRegistry.fetchRemoteCompositionById(compositionId, compositionVersion) + } + } else { + withContext(dispatcherProvider.io()) { + configurationRegistry.fetchRemoteCompositionByAppId(appId) + } } if (compositionResource == null) { diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/ConfigurationRegistryTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/ConfigurationRegistryTest.kt index e93a4a605e..be72888c2f 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/ConfigurationRegistryTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/ConfigurationRegistryTest.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.ImplementationGuide import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.StructureMap @@ -120,7 +121,8 @@ class ConfigurationRegistryTest : RobolectricTest() { } every { secureSharedPreference.retrieveSessionUsername() } returns "demo" - coEvery { configurationRegistry.fetchRemoteComposition(any()) } returns composition + coEvery { configurationRegistry.fetchRemoteCompositionByAppId(any()) } returns composition + coEvery { configurationRegistry.fhirResourceDataSource.getResource(any()) } returns bundle coEvery { configurationRegistry.fhirResourceDataSource.post(any(), any()) } returns bundle every { sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null) } returns "demo" coEvery { configurationRegistry.saveSyncSharedPreferences(any()) } just runs @@ -131,6 +133,21 @@ class ConfigurationRegistryTest : RobolectricTest() { @Test fun testFetchListResourceNonProxy() = runBlocking { + val implementationGuide = + ImplementationGuide().apply { + url = "ImplementationGuide/1" + name = "testImplementationGuide" + definition = + ImplementationGuide.ImplementationGuideDefinitionComponent().apply { + resource = + mutableListOf( + ImplementationGuide.ImplementationGuideDefinitionResourceComponent( + Reference().apply { reference = "Composition" }, + ), + ) + } + } + val composition = Composition().apply { addSection().apply { @@ -148,8 +165,7 @@ class ConfigurationRegistryTest : RobolectricTest() { configurationRegistry.setNonProxy(true) every { secureSharedPreference.retrieveSessionUsername() } returns "demo" - coEvery { configurationRegistry.fetchRemoteComposition(any()) } returns composition - + coEvery { configurationRegistry.fetchRemoteCompositionByAppId(any()) } returns composition coEvery { configurationRegistry.fhirResourceDataSource.getResource(any()) } returns bundle every { sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null) } returns "demo" coEvery { configurationRegistry.saveSyncSharedPreferences(any()) } just runs @@ -184,7 +200,7 @@ class ConfigurationRegistryTest : RobolectricTest() { } every { secureSharedPreference.retrieveSessionUsername() } returns "demo" - coEvery { configurationRegistry.fetchRemoteComposition(any()) } returns composition + coEvery { configurationRegistry.fetchRemoteCompositionByAppId(any()) } returns composition coEvery { fhirResourceService.getResourceWithGatewayModeHeader( ConfigurationRegistry.FHIR_GATEWAY_MODE_HEADER_VALUE, diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt index 905632a332..4ba7ab35c2 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt @@ -49,6 +49,7 @@ import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.ImplementationGuide import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -159,7 +160,16 @@ class AppSettingViewModelTest : RobolectricTest() { @Test fun `fetchConfigurations() should call configurationRegistry#processResultBundleBinaries with correct values`() = runTest { - coEvery { appSettingViewModel.configurationRegistry.fetchRemoteComposition(any()) } returns + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteImplementationGuideByAppId( + any(), + any(), + ) + } returns null + + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) + } returns Composition().apply { addSection().apply { this.focus = @@ -222,7 +232,7 @@ class AppSettingViewModelTest : RobolectricTest() { val binarySlot = slot() - coVerify { appSettingViewModel.configurationRegistry.fetchRemoteComposition(any()) } + coVerify { appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) } coVerify { fhirResourceDataSource.post(any(), any()) } coVerify { defaultRepository.createRemote(any(), any()) } coVerify { @@ -472,8 +482,12 @@ class AppSettingViewModelTest : RobolectricTest() { coEvery { appSettingViewModel.loadConfigurations(any()) } just runs coEvery { appSettingViewModel.isNonProxy() } returns false coEvery { appSettingViewModel.appId } returns MutableLiveData(appId) - coEvery { appSettingViewModel.configurationRegistry.fetchRemoteComposition(appId) } returns - composition + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteImplementationGuideByAppId(any(), any()) + } returns null + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(appId) + } returns composition coEvery { appSettingViewModel.defaultRepository.createRemote(any(), any()) } just runs coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just runs coEvery { @@ -599,4 +613,71 @@ class AppSettingViewModelTest : RobolectricTest() { slot.captured, ) } + + @Test + fun `fetchConfigurations() with an ImplementationGuide should call fetchRemoteCompositionById()`() { + runBlocking { + appSettingViewModel.run { + onApplicationIdChanged("app") + fetchConfigurations(context) + } + val implementationGuide = + ImplementationGuide().apply { + url = "ImplementationGuide/1" + name = "testImplementationGuide" + definition = + ImplementationGuide.ImplementationGuideDefinitionComponent().apply { + resource = + mutableListOf( + ImplementationGuide.ImplementationGuideDefinitionResourceComponent( + Reference().apply { reference = "Composition/_history/1" }, + ), + ) + } + } + val composition = Composition().apply { id = "1" } + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteImplementationGuideByAppId( + any(), + any(), + ) + } returns implementationGuide + coEvery { appSettingViewModel.configurationRegistry.addOrUpdate(any()) } just runs + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteCompositionById(any(), any()) + } returns composition + coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just + runs + coEvery { appSettingViewModel.defaultRepository.createRemote(any(), any()) } just runs + appSettingViewModel.fetchConfigurations(context) + coVerify { + appSettingViewModel.configurationRegistry.fetchRemoteCompositionById(any(), any()) + } + } + } + + @Test + fun `fetchConfigurations() without ImplementationGuide should call fetchRemoteCompositionByAppId()`() { + runBlocking { + appSettingViewModel.run { + onApplicationIdChanged("app") + fetchConfigurations(context) + } + val composition = Composition().apply { id = "123" } + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteImplementationGuideByAppId( + any(), + any(), + ) + } returns null + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) + } returns composition + coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just + runs + coEvery { appSettingViewModel.defaultRepository.createRemote(any(), any()) } just runs + appSettingViewModel.fetchConfigurations(context) + coVerify { appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) } + } + } } diff --git a/docs/engineering/android-app/configuring/config-types/ImplementationGuide.mdx b/docs/engineering/android-app/configuring/config-types/ImplementationGuide.mdx new file mode 100644 index 0000000000..38f70a206d --- /dev/null +++ b/docs/engineering/android-app/configuring/config-types/ImplementationGuide.mdx @@ -0,0 +1,67 @@ +#Implementation Guide +This is a FHIR resource that is used for version management of the application. Currently an Implementation Guide should point to a Composition resource. +The idea is that a given app version should use the resources that a given Composition resource is pointing to. +In the sample JSON below, the Implementation Guide points to a composition with an id 8294. +The valueRange object implies that the composition resource in this Implementation guide supports applications with `version_code` between 1 to 10. + +#Fetching an Implementation Guide. +We need to get an Implementation Guide before user authentication. And for this to work, your project should have a version of [fhir-gateway](https://github.com/onaio/fhir-gateway-extension) that has the ImplementationGuide endpoint whitelisted. +The ideal query to use to fetch an implementation guide is `ImplementationGuide?name=zeir&context-quantity=le10&_sort=-context-quantity&_count=1`. The url queries +`context-quantity=le10&_sort=-context-quantity&_count=1` ensures that you get the Implementation Guide whose useContext.valueRange.high.value is 10 or highest value near 10. + +#Sample JSON +{ + "resourceType": "ImplementationGuide", + "id": "12967310", + "url": "https://fhir.zeir.smartregister.org/fhir/ImplementationGuide/quest", + "version": "1.1.0", + "name": "quest", + "title": "Quest Implementation Guide", + "status": "draft", + "packageId": "org.smartregister.fhircore", + "date": "2024-04-01", + "publisher": "Ona Systems, Inc.", + "fhirVersion": [ + { + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/FHIR-version", + "code": "4.3.0", + "display": "4.3.0" + } + ], + "text": "FHIR Release 4B." + } + } + ], + "useContext": { + "code": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/usage-context-type", + "code": "program", + "display": "Program" + } + ], + "text": "Program" + }, + "valueRange": { + "low": { + "value": 1 + }, + "high": { + "value": 10 + } + } + }, + "definition": { + "resource": [ + { + "reference": { + "reference": "Composition/8294" + } + } + ] + } +} diff --git a/docs/engineering/android-app/configuring/readme.mdx b/docs/engineering/android-app/configuring/readme.mdx index 669faee502..77bd8d7a9d 100644 --- a/docs/engineering/android-app/configuring/readme.mdx +++ b/docs/engineering/android-app/configuring/readme.mdx @@ -13,6 +13,7 @@ OpenSRP supports the following types of configurations: |Config type| Function | Cardinality | |--|--|:--:| +|Implementation Guide| Uniquely identifiers the application. Currently references a composition resource | 1..1 | |composition| Uniquely identifiers the application. References other configurations | 1..1 | | application | Provides application level configurations e.g. languages used in the app, title of the app | 1..1 | | navigation | Configures the app's navigation side menu (drawer). e.g. how many registers to display | 1..1|