diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c16c67ae..0ac130fc90 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: merge_group: branches: [ main ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: FHIRCORE_USERNAME: ${{ secrets.FHIRCORE_USERNAME }} FHIRCORE_ACCESS_TOKEN: ${{ secrets.FHIRCORE_ACCESS_TOKEN }} @@ -21,13 +25,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-level: [30] + api-level: [34] + steps: - - name: Cancel Previous workflow runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - name: Checkout 🛎️ uses: actions/checkout@v4 @@ -106,7 +106,7 @@ jobs: - name: Upload Engine module test coverage report to Codecov - if: matrix.api-level == 30 # Only upload coverage on API level 30 + if: matrix.api-level == 34 # Only upload coverage on API level 34 working-directory: android run: bash <(curl -s https://codecov.io/bash) -F engine -f "engine/build/reports/jacoco/fhircoreJacocoReport/fhircoreJacocoReport.xml" @@ -114,13 +114,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-level: [30] + api-level: [34] + steps: - - name: Cancel Previous workflow runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - name: Checkout 🛎️ uses: actions/checkout@v4 @@ -198,7 +194,7 @@ jobs: path: android/geowidget/build/reports - name: Upload Geowidget module test coverage report to Codecov - if: matrix.api-level == 30 # Only upload coverage on API level 30 + if: matrix.api-level == 34 # Only upload coverage on API level 34 working-directory: android run: bash <(curl -s https://codecov.io/bash) -F geowidget -f "geowidget/build/reports/jacoco/fhircoreJacocoReport/fhircoreJacocoReport.xml" @@ -206,12 +202,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-level: [30] - steps: - - name: Cancel Previous workflow runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} + api-level: [34] + + steps: - name: Checkout 🛎️ uses: actions/checkout@v4 @@ -315,6 +308,6 @@ jobs: path: android/quest/build/reports - name: Upload Quest module test coverage report to Codecov - if: matrix.api-level == 30 # Only upload coverage on API level 30 + if: matrix.api-level == 34 # Only upload coverage on API level 34 working-directory: android - run: bash <(curl -s https://codecov.io/bash) -F quest -f "quest/build/reports/jacoco/fhircoreJacocoReport/fhircoreJacocoReport.xml" \ No newline at end of file + run: bash <(curl -s https://codecov.io/bash) -F quest -f "quest/build/reports/jacoco/fhircoreJacocoReport/fhircoreJacocoReport.xml" diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index 6616981e4a..d62fc972b5 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -3,7 +3,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { `jacoco-report` - `ktlint` + ktlint id("com.android.library") id("kotlin-android") id("kotlin-kapt") @@ -162,7 +162,7 @@ dependencies { api(libs.glide) api(libs.knowledge) { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.p2p.lib) - api(libs.jjwt) + api(libs.java.jwt) api(libs.fhir.common.utils) { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.runtime.livedata) api(libs.foundation) @@ -180,8 +180,8 @@ dependencies { api(libs.data.capture) { isTransitive = true exclude(group = "ca.uhn.hapi.fhir") - exclude(group = "com.google.android.fhir", module = "engine") exclude(group = "com.google.android.fhir", module = "common") + exclude(group = "org.smartregister", module = "common") exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.cqf.fhir.cr) { @@ -194,23 +194,19 @@ dependencies { exclude(group = "xerces") exclude(group = "com.github.java-json-tools") exclude(group = "org.codehaus.woodstox") - exclude(group = "com.google.android.fhir", module = "common") exclude(group = "com.google.android.fhir", module = "engine") + exclude(group = "org.smartregister", module = "engine") exclude(group = "com.github.ben-manes.caffeine") } api(libs.contrib.barcode) { isTransitive = true exclude(group = "org.smartregister", module = "data-capture") exclude(group = "ca.uhn.hapi.fhir") - exclude(group = "com.google.android.fhir", module = "common") - exclude(group = "com.google.android.fhir", module = "engine") } api(libs.contrib.locationwidget) { isTransitive = true exclude(group = "org.smartregister", module = "data-capture") exclude(group = "ca.uhn.hapi.fhir") - exclude(group = "com.google.android.fhir", module = "common") - exclude(group = "com.google.android.fhir", module = "engine") } api(libs.fhir.engine) { isTransitive = true diff --git a/android/engine/src/androidTest/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionKtTest.kt b/android/engine/src/androidTest/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionKtTest.kt index 8862f5e123..162818f6e9 100644 --- a/android/engine/src/androidTest/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionKtTest.kt +++ b/android/engine/src/androidTest/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionKtTest.kt @@ -25,6 +25,7 @@ import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.search.search import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Resource @@ -57,19 +58,15 @@ class FhirEngineExtensionKtTest { } @Test - fun test_search_time_searches_sequentially_and_short_running_query_waits() { + fun test_search_time_searches_sequentially_and_short_running_query_waits() = runTest { val fetchedResources = mutableListOf() - runBlocking { - launch { - val patients = fhirEngine.search {}.map { it.resource } - fetchedResources += patients - } - launch { - val questionnaires = fhirEngine.search {}.map { it.resource } - fetchedResources += questionnaires - } - } + val patients = fhirEngine.search {}.map { it.resource } + fetchedResources += patients + + val questionnaires = fhirEngine.search {}.map { it.resource } + fetchedResources += questionnaires + val indexOfResultOfShortQuery = fetchedResources.indexOfFirst { it.resourceType == ResourceType.Questionnaire } val indexOfResultOfLongQuery = 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 9287b4667f..c00d046513 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 @@ -105,7 +105,6 @@ constructor( private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK private val fhirContext = FhirContext.forR4Cached() private val authConfiguration = configService.provideAuthConfiguration() - private val jsonParser = fhirContext.newJsonParser() /** * Retrieve configuration for the provided [ConfigType]. The JSON retrieved from [configsJsonMap] @@ -630,9 +629,14 @@ constructor( resource.idElement.idPart } - return File(context.filesDir, "$fileName.json").apply { - writeText(jsonParser.encodeResourceToString(resource)) - } + return File( + context.filesDir, + "$KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER/${resource.resourceType}/$fileName.json", + ) + .apply { + this.parentFile?.mkdirs() + writeText(fhirContext.newJsonParser().encodeResourceToString(resource)) + } } /** @@ -818,6 +822,7 @@ constructor( const val PAGINATION_NEXT = "next" const val RESOURCES_PATH = "resources/" const val SYNC_LOCATION_IDS = "_syncLocations" + const val KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER = "km" /** * The list of resources whose types can be synced down as part of the Composition configs. diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/PdfConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/PdfConfig.kt new file mode 100644 index 0000000000..819d95eb85 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/PdfConfig.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.configuration + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.smartregister.fhircore.engine.util.extension.interpolate + +@Serializable +@Parcelize +data class PdfConfig( + val title: String? = null, + val titleSuffix: String? = null, + val structureReference: String? = null, + val subjectReference: String? = null, + val questionnaireReferences: List = emptyList(), +) : java.io.Serializable, Parcelable { + + fun interpolate(computedValuesMap: Map) = + this.copy( + title = title?.interpolate(computedValuesMap), + titleSuffix = titleSuffix?.interpolate(computedValuesMap), + structureReference = structureReference?.interpolate(computedValuesMap), + subjectReference = subjectReference?.interpolate(computedValuesMap), + questionnaireReferences = questionnaireReferences.map { it.interpolate(computedValuesMap) }, + ) +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt index 0c5bc2f57c..89ff99b756 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt @@ -66,8 +66,6 @@ data class QuestionnaireConfig( val managingEntityRelationshipCode: String? = null, val uniqueIdAssignment: UniqueIdAssignmentConfig? = null, val linkIds: List? = null, - val htmlBinaryId: String? = null, - val htmlTitle: String? = null, ) : java.io.Serializable, Parcelable { fun interpolate(computedValuesMap: Map) = @@ -102,8 +100,6 @@ data class QuestionnaireConfig( uniqueIdAssignment?.copy(linkId = uniqueIdAssignment.linkId.interpolate(computedValuesMap)), linkIds = linkIds?.onEach { it.linkId.interpolate(computedValuesMap) }, saveButtonText = saveButtonText?.interpolate(computedValuesMap), - htmlBinaryId = htmlBinaryId?.interpolate(computedValuesMap), - htmlTitle = htmlTitle?.interpolate(computedValuesMap), ) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt index 5e5ed2579a..5525aead9e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt @@ -116,18 +116,15 @@ constructor( @ApplicationContext open val context: Context, ) { - suspend inline fun loadResource(resourceId: String): T? { - return withContext(dispatcherProvider.io()) { fhirEngine.loadResource(resourceId) } - } + suspend inline fun loadResource(resourceId: String): T? = + fhirEngine.loadResource(resourceId) suspend fun loadResource(resourceId: String, resourceType: ResourceType): Resource = - withContext(dispatcherProvider.io()) { fhirEngine.get(resourceType, resourceId) } + fhirEngine.get(resourceType, resourceId) suspend fun loadResource(reference: Reference) = - withContext(dispatcherProvider.io()) { - IdType(reference.reference).let { - fhirEngine.get(ResourceType.fromCode(it.resourceType), it.idPart) - } + IdType(reference.reference).let { + fhirEngine.get(ResourceType.fromCode(it.resourceType), it.idPart) } suspend inline fun searchResourceFor( @@ -137,19 +134,17 @@ constructor( dataQueries: List = listOf(), configComputedRuleValues: Map, ): List = - withContext(dispatcherProvider.io()) { - fhirEngine - .batchedSearch { - filterByResourceTypeId(token, subjectType, subjectId) - dataQueries.forEach { - filterBy( - dataQuery = it, - configComputedRuleValues = configComputedRuleValues, - ) - } + fhirEngine + .batchedSearch { + filterByResourceTypeId(token, subjectType, subjectId) + dataQueries.forEach { + filterBy( + dataQuery = it, + configComputedRuleValues = configComputedRuleValues, + ) } - .map { it.resource } - } + } + .map { it.resource } suspend inline fun search(search: Search) = fhirEngine.batchedSearch(search).map { it.resource } @@ -164,17 +159,13 @@ constructor( * param [addResourceTags] */ suspend fun create(addResourceTags: Boolean = true, vararg resource: Resource): List { - return withContext(dispatcherProvider.io()) { - preProcessResources(addResourceTags, *resource) - fhirEngine.create(*resource) - } + preProcessResources(addResourceTags, *resource) + return fhirEngine.create(*resource) } suspend fun createRemote(addResourceTags: Boolean = true, vararg resource: Resource) { - return withContext(dispatcherProvider.io()) { - preProcessResources(addResourceTags, *resource) - fhirEngine.create(*resource, isLocalOnly = true) - } + preProcessResources(addResourceTags, *resource) + fhirEngine.create(*resource, isLocalOnly = true) } private fun preProcessResources(addResourceTags: Boolean, vararg resource: Resource) { @@ -204,23 +195,19 @@ constructor( resourceId: String, softDelete: Boolean = false, ) { - withContext(dispatcherProvider.io()) { - if (softDelete) { - val resource = fhirEngine.get(resourceType, resourceId) - softDelete(resource) - } else { - fhirEngine.delete(resourceType, resourceId) - } + if (softDelete) { + val resource = fhirEngine.get(resourceType, resourceId) + softDelete(resource) + } else { + fhirEngine.delete(resourceType, resourceId) } } suspend fun delete(resource: Resource, softDelete: Boolean = false) { - withContext(dispatcherProvider.io()) { - if (softDelete) { - softDelete(resource) - } else { - fhirEngine.delete(resource.resourceType, resource.logicalId) - } + if (softDelete) { + softDelete(resource) + } else { + fhirEngine.delete(resource.resourceType, resource.logicalId) } } @@ -249,24 +236,20 @@ constructor( * param [addMandatoryTags] */ suspend fun addOrUpdate(addMandatoryTags: Boolean = true, resource: R) { - return withContext(dispatcherProvider.io()) { - resource.updateLastUpdated() - try { - fhirEngine.get(resource.resourceType, resource.logicalId).run { - val updateFrom = updateFrom(resource) - fhirEngine.update(updateFrom) - } - } catch (resourceNotFoundException: ResourceNotFoundException) { - create(addMandatoryTags, resource) + resource.updateLastUpdated() + try { + fhirEngine.get(resource.resourceType, resource.logicalId).run { + val updateFrom = updateFrom(resource) + fhirEngine.update(updateFrom) } + } catch (resourceNotFoundException: ResourceNotFoundException) { + create(addMandatoryTags, resource) } } suspend fun update(resource: R) { - return withContext(dispatcherProvider.io()) { - resource.updateLastUpdated() - fhirEngine.update(resource) - } + resource.updateLastUpdated() + fhirEngine.update(resource) } suspend fun loadManagingEntity(group: Group) = @@ -910,7 +893,7 @@ constructor( val updatedResource = parser.parseResource(resourceDefinition, updatedResourceDocument.jsonString()) updatedResource.setId(updatedResource.idElement.idPart) - withContext(dispatcherProvider.io()) { fhirEngine.update(updatedResource as Resource) } + fhirEngine.update(updatedResource as Resource) } private fun getJsonContent(jsonElement: JsonElement): Any? { @@ -941,9 +924,7 @@ constructor( suspend fun purge(resource: Resource, forcePurge: Boolean) { try { - withContext(dispatcherProvider.io()) { - fhirEngine.purge(resource.resourceType, resource.logicalId, forcePurge) - } + fhirEngine.purge(resource.resourceType, resource.logicalId, forcePurge) } catch (resourceNotFoundException: ResourceNotFoundException) { Timber.e( "Purge failed -> Resource with ID ${resource.logicalId} does not exist", diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt index efa101b299..a3daf5035a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt @@ -28,11 +28,12 @@ import android.os.Handler import android.os.Looper import android.os.Message import androidx.core.os.bundleOf +import com.auth0.jwt.JWT +import com.auth0.jwt.exceptions.JWTDecodeException +import com.auth0.jwt.interfaces.DecodedJWT import com.google.android.fhir.sync.HttpAuthenticationMethod import com.google.android.fhir.sync.HttpAuthenticator as FhirAuthenticator import dagger.hilt.android.qualifiers.ApplicationContext -import io.jsonwebtoken.JwtException -import io.jsonwebtoken.Jwts import java.io.IOException import java.net.UnknownHostException import java.util.Base64 @@ -62,7 +63,6 @@ constructor( @ApplicationContext val context: Context, ) : FhirAuthenticator { - private val jwtParser = Jwts.parser() private val authConfiguration by lazy { configService.provideAuthConfiguration() } private var isLoginPageRendered = false @@ -131,12 +131,11 @@ constructor( /** This function checks if token is null or empty or expired */ fun isTokenActive(authToken: String?): Boolean { if (authToken.isNullOrEmpty()) return false - val tokenPart = authToken.substringBeforeLast('.').plus(".") return try { - val body = jwtParser.parseClaimsJwt(tokenPart).body - body.expiration.after(today()) - } catch (jwtException: JwtException) { - false + val jwt: DecodedJWT? = JWT.decode(authToken) + jwt?.expiresAt!!.after(today()) + } catch (e: JWTDecodeException) { + return false } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt index a3a1eb6d01..7ffedd809b 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt @@ -27,10 +27,14 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.io.File +import java.io.FileInputStream import javax.inject.Singleton import org.hl7.fhir.r4.context.SimpleWorkerContext import org.hl7.fhir.r4.model.Parameters +import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.utils.FHIRPathEngine +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.util.helper.TransformSupportServices @InstallIn(SingletonComponent::class) @@ -39,10 +43,29 @@ class CoreModule { @Singleton @Provides - fun provideWorkerContextProvider(): SimpleWorkerContext = + fun provideWorkerContextProvider(@ApplicationContext context: Context): SimpleWorkerContext = SimpleWorkerContext().apply { setExpansionProfile(Parameters()) isCanRunWithoutTerminology = true + context.filesDir + .resolve(ConfigurationRegistry.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER) + .list() + ?.forEach { resourceFolder -> + context.filesDir + .resolve("${ConfigurationRegistry.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/$resourceFolder") + .list() + ?.forEach { file -> + cacheResource( + FhirContext.forR4Cached() + .newJsonParser() + .parseResource( + FileInputStream( + File(context.filesDir.resolve("km/$resourceFolder/$file").toString()), + ), + ) as Resource, + ) + } + } } @Singleton diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt index 0bd988d40b..3b9f5b3acd 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt @@ -22,6 +22,7 @@ import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.configuration.PdfConfig import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.profile.ManagingEntityConfig import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger @@ -42,6 +43,7 @@ data class ActionConfig( val toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, val popNavigationBackStack: Boolean? = null, val multiSelectViewConfig: MultiSelectViewConfig? = null, + val pdfConfig: PdfConfig? = null, ) : Parcelable, java.io.Serializable { fun paramsBundle(computedValuesMap: Map = emptyMap()): Bundle = Bundle().apply { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/QuestionnaireType.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/QuestionnaireType.kt index bf3fce59b7..ba490a4ec7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/QuestionnaireType.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/QuestionnaireType.kt @@ -22,6 +22,7 @@ enum class QuestionnaireType { DEFAULT, EDIT, READ_ONLY, + SUMMARY, } fun QuestionnaireConfig.isDefault() = @@ -32,3 +33,6 @@ fun QuestionnaireConfig.isEditable() = fun QuestionnaireConfig.isReadOnly() = QuestionnaireType.valueOf(this.type) == QuestionnaireType.READ_ONLY + +fun QuestionnaireConfig.isSummary() = + QuestionnaireType.valueOf(this.type) == QuestionnaireType.SUMMARY diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt index 474f2c0da8..2cd52f88ab 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt @@ -16,8 +16,6 @@ package org.smartregister.fhircore.engine.p2p.dao -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.rest.gclient.DateClientParam import ca.uhn.fhir.rest.gclient.StringClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum @@ -49,8 +47,6 @@ constructor( open val configurationRegistry: ConfigurationRegistry, ) { - protected val jsonParser: IParser = FhirContext.forR4Cached().newJsonParser() - open fun getDataTypes(): TreeSet { val appRegistry = configurationRegistry.retrieveConfiguration(ConfigType.Application) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt index 24270c0215..ddc1d1d759 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.engine.p2p.dao +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.extensions.logicalId import java.util.TreeSet @@ -47,7 +48,9 @@ constructor( (0 until jsonArray.length()).forEach { runBlocking { val resource = - jsonParser.parseResource(type.name.resourceClassType(), jsonArray.get(it).toString()) + FhirContext.forR4Cached() + .newJsonParser() + .parseResource(type.name.resourceClassType(), jsonArray.get(it).toString()) val recordLastUpdated = resource.meta.lastUpdated.time defaultRepository.addOrUpdate(resource = resource) maxLastUpdated = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt index f6a0c18123..ce537fb3b7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.engine.p2p.dao +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.extensions.logicalId import java.util.TreeSet @@ -76,7 +77,7 @@ constructor( val jsonArray = JSONArray() records.forEach { - jsonArray.put(jsonParser.encodeResourceToString(it.resource)) + jsonArray.put(FhirContext.forR4Cached().newJsonParser().encodeResourceToString(it.resource)) highestRecordId = if (it.resource.meta?.lastUpdated?.time!! > highestRecordId) { it.resource.meta?.lastUpdated?.time!! diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt index bcdb51f226..7b49518eea 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt @@ -16,32 +16,55 @@ package org.smartregister.fhircore.engine.pdf +import java.util.Date import java.util.regex.Matcher import java.util.regex.Pattern import org.hl7.fhir.r4.model.BaseDateTimeType import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent import org.smartregister.fhircore.engine.util.extension.allItems +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.formatDate import org.smartregister.fhircore.engine.util.extension.makeItReadable import org.smartregister.fhircore.engine.util.extension.valueToString /** * HtmlPopulator class is responsible for processing an HTML template by replacing custom tags with - * data from a QuestionnaireResponse. The class uses various regex patterns to find and replace - * custom tags such as @is-not-empty, @answer-as-list, @answer, @submitted-date, and @contains. + * data from QuestionnaireResponses. The class uses various regex patterns to find and replace + * custom tags such as @is-not-empty, @answer-as-list, @answer, @submitted-date, @contains, + * and @is-questionnaire-submitted. * - * @property questionnaireResponse The QuestionnaireResponse object containing data for replacement. + * @property questionnaireResponses The QuestionnaireResponses object containing data for + * replacement. */ class HtmlPopulator( - private val questionnaireResponse: QuestionnaireResponse, + questionnaireResponses: List, ) { + private var answerMap: Map> + private var submittedDateMap: Map + private var questionnaireIds: List - // Map to store questionnaire response items keyed by their linkId - private val questionnaireResponseItemMap = - questionnaireResponse.allItems.associateBy( - keySelector = { it.linkId }, - valueTransform = { it.answer }, - ) + init { + val answerMap = mutableMapOf>() + val submittedDateMap = mutableMapOf() + val questionnaireIds = mutableListOf() + + questionnaireResponses.forEach { questionnaireResponse -> + val questionnaireId = questionnaireResponse.questionnaire.extractLogicalIdUuid() + questionnaireResponse.allItems + .associateBy( + keySelector = { "$questionnaireId/${it.linkId}" }, + valueTransform = { it.answer }, + ) + .let { answerMap.putAll(it) } + submittedDateMap[questionnaireId] = questionnaireResponse.meta.lastUpdated ?: Date() + questionnaireIds.add(questionnaireId) + } + + this.answerMap = answerMap + this.submittedDateMap = submittedDateMap + this.questionnaireIds = questionnaireIds + } /** * Populates the provided HTML template with data from the QuestionnaireResponse. @@ -77,6 +100,10 @@ class HtmlPopulator( val matcher = containsPattern.matcher(html.substring(i)) if (matcher.find()) processContains(i, html, matcher) else i++ } + html.startsWith("@is-questionnaire-submitted", i) -> { + val matcher = isQuestionnaireSubmittedPattern.matcher(html.substring(i)) + if (matcher.find()) processIsQuestionnaireSubmitted(i, html, matcher) else i++ + } else -> i++ } } @@ -94,7 +121,7 @@ class HtmlPopulator( private fun processIsNotEmpty(i: Int, html: StringBuilder, matcher: Matcher) { val linkId = matcher.group(1) val content = matcher.group(2) ?: "" - val doesAnswerExist = questionnaireResponseItemMap.getOrDefault(linkId, listOf()).isNotEmpty() + val doesAnswerExist = answerMap.getOrDefault(linkId, listOf()).isNotEmpty() if (doesAnswerExist) { html.replace(i, matcher.end() + i, content) // Start index is the index of '@' symbol, End index is the index after the ')' symbol. @@ -119,8 +146,7 @@ class HtmlPopulator( private fun processAnswerAsList(i: Int, html: StringBuilder, matcher: Matcher) { val linkId = matcher.group(1) val answerAsList = - questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString(separator = "") { - answer -> + answerMap.getOrDefault(linkId, listOf()).joinToString(separator = "") { answer -> "
  • ${answer.value.valueToString()}
  • " } html.replace(i, matcher.end() + i, answerAsList) @@ -137,7 +163,7 @@ class HtmlPopulator( val linkId = matcher.group(1) val dateFormat = matcher.group(2) val answer = - questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString { answer -> + answerMap.getOrDefault(linkId, listOf()).joinToString { answer -> if (dateFormat == null) { answer.value.valueToString() } else { @@ -155,12 +181,13 @@ class HtmlPopulator( * @param matcher The Matcher object for the regex pattern. */ private fun processSubmittedDate(i: Int, html: StringBuilder, matcher: Matcher) { - val dateFormat = matcher.group(1) + val questionnaireId = matcher.group(1) + val dateFormat = matcher.group(2) val date = if (dateFormat == null) { - questionnaireResponse.meta.lastUpdated.formatDate() + submittedDateMap.getOrDefault(questionnaireId, Date()).formatDate() } else { - questionnaireResponse.meta.lastUpdated.formatDate(dateFormat) + submittedDateMap.getOrDefault(questionnaireId, Date()).formatDate(dateFormat) } html.replace(i, matcher.end() + i, date) } @@ -178,7 +205,7 @@ class HtmlPopulator( val indicator = matcher.group(2) ?: "" val content = matcher.group(3) ?: "" val doesAnswerExist = - questionnaireResponseItemMap.getOrDefault(linkId, listOf()).any { + answerMap.getOrDefault(linkId, listOf()).any { when { it.hasValueCoding() -> it.valueCoding.code == indicator it.hasValueStringType() -> it.valueStringType.value.contains(indicator) @@ -199,14 +226,39 @@ class HtmlPopulator( } } + /** + * Processes the @is-questionnaire-submitted tag by checking if the corresponding + * [QuestionnaireResponse] exists. Replaces the tag with the content if the indicator is true, + * otherwise removes the tag. + * + * @param i The starting index of the tag in the HTML. + * @param html The StringBuilder containing the HTML. + * @param matcher The Matcher object for the regex pattern. + */ + private fun processIsQuestionnaireSubmitted(i: Int, html: StringBuilder, matcher: Matcher) { + val id = matcher.group(1) + val content = matcher.group(2) ?: "" + val doesQuestionnaireExists = questionnaireIds.contains(id) + if (doesQuestionnaireExists) { + html.replace(i, matcher.end() + i, content) + } else { + html.replace(i, matcher.end() + i, "") + } + } + companion object { // Compile regex patterns for different tags private val isNotEmptyPattern = Pattern.compile("@is-not-empty\\('([^']+)'\\)((?s).*?)@is-not-empty\\('\\1'\\)") private val answerAsListPattern = Pattern.compile("@answer-as-list\\('([^']+)'\\)") private val answerPattern = Pattern.compile("@answer\\('([^']+)'(?:,'([^']+)')?\\)") - private val submittedDatePattern = Pattern.compile("@submitted-date(?:\\('([^']+)'\\))?") + private val submittedDatePattern = + Pattern.compile("@submitted-date\\('([^']+)'(?:,'([^']+)')?\\)") private val containsPattern = Pattern.compile("@contains\\('([^']+)','([^']+)'\\)((?s).*?)@contains\\('\\1'\\)") + private val isQuestionnaireSubmittedPattern = + Pattern.compile( + "@is-questionnaire-submitted\\('([^']+)'\\)((?s).*?)@is-questionnaire-submitted\\('\\1'\\)", + ) } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt index 4a5513ae25..0421ab1610 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt @@ -51,6 +51,7 @@ import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.domain.model.ServiceMemberIcon import org.smartregister.fhircore.engine.domain.model.ServiceStatus +import org.smartregister.fhircore.engine.rulesengine.services.DateService import org.smartregister.fhircore.engine.rulesengine.services.LocationService import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.SDF_DD_MMM_YYYY @@ -102,6 +103,7 @@ constructor( put(DATA, mutableMapOf().apply { putAll(params) }) put(LOCATION_SERVICE, locationService) put(SERVICE, rulesEngineService) + put(DATE_SERVICE, DateService) } if (repositoryResourceData != null) { with(repositoryResourceData) { @@ -149,8 +151,6 @@ constructor( /** Provide access to utility functions accessible to the users defining rules in JSON format. */ inner class RulesEngineService { - val parser = fhirContext.newJsonParser() - private var conf: Configuration = Configuration.defaultConfiguration().apply { addOptions(Option.DEFAULT_PATH_LEAF_TO_NULL) } @@ -681,7 +681,9 @@ constructor( } val updatedResource = - parser.parseResource(resource::class.java, updatedResourceDocument.jsonString()) + fhirContext + .newJsonParser() + .parseResource(resource::class.java, updatedResourceDocument.jsonString()) CoroutineScope(dispatcherProvider.io()).launch { if (purgeAffectedResources) { defaultRepository.purge(updatedResource as Resource, forcePurge = true) @@ -709,6 +711,7 @@ constructor( companion object { private const val SERVICE = "service" private const val LOCATION_SERVICE = "locationService" + private const val DATE_SERVICE = "dateService" private const val INCLUSIVE_SIX_DIGIT_MINIMUM = 100000 private const val INCLUSIVE_SIX_DIGIT_MAXIMUM = 999999 private const val DEFAULT_REGEX = "(?<=^|,)[\\s,]*(\\w[\\w\\s]*)(?=[\\s,]*$|,)" 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 a5642cad2b..89f020b8fe 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 @@ -20,7 +20,6 @@ import android.content.Context import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.rest.gclient.ReferenceClientParam -import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.get import java.time.Duration @@ -184,22 +183,6 @@ fun JSONObject.updateFrom(updated: JSONObject) { keys.forEach { key -> updated.opt(key)?.run { put(key, this) } } } -fun QuestionnaireResponse.generateMissingItems(questionnaire: Questionnaire) = - questionnaire.item.generateMissingItems(this.item) - -fun List.generateMissingItems( - qrItems: MutableList, -) { - this.forEachIndexed { index, qItem -> - // generate complete hierarchy if response item missing otherwise check for nested items - if (qrItems.isEmpty() || (index < qrItems.size && qItem.linkId != qrItems[index].linkId)) { - qrItems.add(index, qItem.createQuestionnaireResponseItem()) - } else if (index < qrItems.size) { - qItem.item.generateMissingItems(qrItems[index].item) - } - } -} - /** * Set all questions that are not of type [Questionnaire.QuestionnaireItemType.GROUP] to readOnly if * [readOnly] is true. This also generates the correct FHIRPath population expression for each diff --git a/android/engine/src/main/res/values/styles.xml b/android/engine/src/main/res/values/styles.xml index fd15edd98b..eb95cda512 100644 --- a/android/engine/src/main/res/values/styles.xml +++ b/android/engine/src/main/res/values/styles.xml @@ -63,6 +63,7 @@ @style/TextAppearance.Material3.BodyLarge @style/AppTheme.MediaImageStyle @style/AppTheme.HelpHeaderStyle + @style/AppTheme.NextButtonCircularProgressIndicatorStyle + +