diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90c4b4bc62..cdb47307ff 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,11 +272,7 @@ jobs: force-avd-creation: true emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:connectedOpensrpDebugAndroidTest --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance - - - name: Test UnitTest - run: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:testOpensrpDebugUnitTest --stacktrace - working-directory: android + script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance - name: Run Quest module unit and instrumentation tests and generate aggregated coverage report (Disabled) if: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6de70910..57c1828cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 1. Added a new class (PdfGenerator) for generating PDF documents from HTML content using Android's WebView and PrintManager 2. Introduced a new class (HtmlPopulator) to populate HTML templates with data from a Questionnaire Response 3. Implemented functionality to launch PDF generation using a configuration setup -- Added Save draft MVP functionality +- Added Save draft MVP functionality +- Added Delete saved draft feature +- Add configurable confirmation dialog on form submission ## [1.1.0] - 2024-02-15 diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index 753fbae6ce..f26c0adfb0 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -147,6 +147,7 @@ dependencies { implementation(libs.slf4j.nop) implementation(libs.fhir.sdk.common) + // Shared dependencies api(libs.bundles.datastore.kt) api(libs.bundles.navigation) api(libs.bundles.materialicons) @@ -158,8 +159,6 @@ dependencies { api(libs.bundles.okhttp3) api(libs.bundles.paging) api(libs.ui) - - // Shared dependencies api(libs.glide) api(libs.knowledge) { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.p2p.lib) @@ -197,6 +196,7 @@ dependencies { exclude(group = "com.google.android.fhir", module = "engine") exclude(group = "org.smartregister", module = "engine") exclude(group = "com.github.ben-manes.caffeine") + exclude(group = "com.google.android.fhir", module = "knowledge") } api(libs.contrib.barcode) { isTransitive = true 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 50579cce38..c8521b3f20 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 @@ -34,8 +34,6 @@ import java.util.PropertyResourceBundle import java.util.ResourceBundle import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.RequestBody.Companion.toRequestBody @@ -100,7 +98,6 @@ constructor( val localizationHelper: LocalizationHelper by lazy { LocalizationHelper(this) } private val supportedFileExtensions = listOf("json", "properties") private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK - private val mutex = Mutex() /** * Retrieve configuration for the provided [ConfigType]. The JSON retrieved from [configsJsonMap] @@ -592,34 +589,30 @@ constructor( * Note */ suspend fun addOrUpdate(resource: R) { - withContext(dispatcherProvider.io()) { - try { - createOrUpdateRemote(resource) - } catch (sqlException: SQLException) { - Timber.e(sqlException) - } + try { + createOrUpdateRemote(resource) + } catch (sqlException: SQLException) { + Timber.e(sqlException) + } - /** - * Knowledge manager [MetadataResource]s install. Here we install all resources types of - * [MetadataResource] as per FHIR Spec.This supports future use cases as well - */ - try { - if (resource is MetadataResource) { - mutex.withLock { - knowledgeManager.install( - KnowledgeManagerUtil.writeToFile( - context = context, - configService = configService, - metadataResource = resource, - subFilePath = - "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/${resource.resourceType}/${resource.idElement.idPart}.json", - ), - ) - } - } - } catch (exception: Exception) { - Timber.e(exception) + /** + * Knowledge manager [MetadataResource]s install. Here we install all resources types of + * [MetadataResource] as per FHIR Spec.This supports future use cases as well + */ + try { + if (resource is MetadataResource) { + knowledgeManager.install( + KnowledgeManagerUtil.writeToFile( + context = context, + configService = configService, + metadataResource = resource, + subFilePath = + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/${resource.resourceType}/${resource.idElement.idPart}.json", + ), + ) } + } catch (exception: Exception) { + Timber.e(exception) } } @@ -632,13 +625,11 @@ constructor( * @param resources vararg of resources */ suspend fun createOrUpdateRemote(vararg resources: Resource) { - return withContext(dispatcherProvider.io()) { - resources.onEach { - it.updateLastUpdated() - it.generateMissingId() - } - fhirEngine.create(*resources, isLocalOnly = true) + resources.onEach { + it.updateLastUpdated() + it.generateMissingId() } + fhirEngine.create(*resources, isLocalOnly = true) } @VisibleForTesting fun isNonProxy(): Boolean = _isNonProxy @@ -750,7 +741,7 @@ constructor( it.system.contentEquals(organizationResourceTag?.tag?.system, ignoreCase = true) } ?.code - COUNT -> appConfig.remoteSyncPageSize.toString() + COUNT -> DEFAULT_COUNT.toString() else -> paramExpression }?.let { paramExpression?.replace(paramLiteral, it) } @@ -801,7 +792,7 @@ constructor( const val MANIFEST_PROCESSOR_BATCH_SIZE = 20 const val ORGANIZATION = "organization" const val TYPE_REFERENCE_DELIMITER = "/" - const val DEFAULT_COUNT = 200 + const val DEFAULT_COUNT = 1000 const val PAGINATION_NEXT = "next" const val RESOURCES_PATH = "resources/" const val SYNC_LOCATION_IDS = "_syncLocations" 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 ff865ce566..0fd117df60 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 @@ -67,6 +67,7 @@ data class QuestionnaireConfig( val uniqueIdAssignment: UniqueIdAssignmentConfig? = null, val linkIds: List? = null, val showSubmitAnywayButton: String = "false", + val showSubmissionConfirmationDialog: String = "false", ) : java.io.Serializable, Parcelable { fun interpolate(computedValuesMap: Map) = @@ -102,6 +103,8 @@ data class QuestionnaireConfig( linkIds = linkIds?.onEach { it.linkId.interpolate(computedValuesMap) }, saveButtonText = saveButtonText?.interpolate(computedValuesMap), showSubmitAnywayButton = showSubmitAnywayButton.interpolate(computedValuesMap), + showSubmissionConfirmationDialog = + showSubmissionConfirmationDialog.interpolate(computedValuesMap), ) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index 27076b4a8f..c3479e1dcf 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -19,6 +19,7 @@ package org.smartregister.fhircore.engine.configuration.app import kotlinx.serialization.Serializable import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.Configuration +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.event.EventWorkflow import org.smartregister.fhircore.engine.domain.model.LauncherType import org.smartregister.fhircore.engine.util.extension.DEFAULT_FORMAT_SDF_DD_MM_YYYY @@ -28,7 +29,7 @@ data class ApplicationConfiguration( override var appId: String, override var configType: String = ConfigType.Application.name, val appTitle: String = "", - val remoteSyncPageSize: Int = 100, + val remoteSyncPageSize: Int = ConfigurationRegistry.DEFAULT_COUNT, val languages: List = listOf("en"), val useDarkTheme: Boolean = false, val syncInterval: Long = 15, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt index 502cb2cde8..d7d642960e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt @@ -34,6 +34,9 @@ interface ConfigService { /** Define a list of [ResourceTag] for the application. */ fun defineResourceTags(): List + /** Return the App's launcher icon for use in notifications */ + fun getLauncherIcon(): Int + /** * Provide a list of [Coding] that represents [ResourceTag]. [Coding] can be directly appended to * a FHIR resource. diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/migration/MigrationConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/migration/MigrationConfig.kt index 4f3a67a895..9f84130e71 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/migration/MigrationConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/migration/MigrationConfig.kt @@ -25,7 +25,7 @@ import org.smartregister.fhircore.engine.domain.model.RuleConfig data class MigrationConfig( val updateValues: List, val resourceConfig: FhirResourceConfig, - val rules: List, + val rules: List = emptyList(), val version: Int, val purgeAffectedResources: Boolean = false, val createLocalChangeEntitiesAfterPurge: Boolean = true, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt index 7f6d5c7ddd..237ac41f8e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt @@ -39,7 +39,7 @@ data class RegisterConfiguration( val registerCard: RegisterCardConfig = RegisterCardConfig(), val fabActions: List = emptyList(), val noResults: NoResultsConfig? = null, - val pageSize: Int = 10, + val pageSize: Int = 20, val activeResourceFilters: List = listOf( ActiveResourceFilterConfig(resourceType = ResourceType.Patient, active = true), diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt index 43ba2ab4a1..973a335ba4 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt @@ -24,7 +24,7 @@ data class RegisterContentConfig( val separator: String? = null, val display: String? = null, val placeholderColor: String? = null, - val rules: List? = null, + val rules: List = emptyList(), val visible: Boolean? = null, val computedRules: List? = null, val searchByQrCode: Boolean? = null, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt index ea58e771f5..68848c238e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt @@ -62,4 +62,7 @@ enum class ApplicationWorkflow { /** A workflow to launch pdf generation */ LAUNCH_PDF_GENERATION, + + /** A workflow to launch delete draft questionnaires */ + DELETE_DRAFT_QUESTIONNAIRE, } 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 63c0e4fc66..8321e873c9 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 @@ -46,7 +46,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.util.LinkedList import java.util.UUID import javax.inject.Inject -import kotlin.math.min +import javax.inject.Singleton import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -57,11 +57,14 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.hl7.fhir.instance.model.api.IBaseResource +import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.Location import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource @@ -99,9 +102,14 @@ import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyn import org.smartregister.fhircore.engine.util.extension.updateFrom import org.smartregister.fhircore.engine.util.extension.updateLastUpdated import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor -import org.smartregister.fhircore.engine.util.pmap import timber.log.Timber +typealias SearchQueryResultQueue = + ArrayDeque, ResourceConfig, Map>> + +typealias RelatedResourcesQueue = ArrayDeque, ResourceConfig, String>> + +@Singleton open class DefaultRepository @Inject constructor( @@ -474,131 +482,19 @@ constructor( } } - protected suspend fun retrieveRelatedResources( - resource: Resource, - relatedResourcesConfigs: List?, - configComputedRuleValues: Map, - ): RelatedResourceWrapper { - val relatedResourceWrapper = RelatedResourceWrapper() - val relatedResourcesQueue = - ArrayDeque, List?>>().apply { - addFirst(Pair(listOf(resource), relatedResourcesConfigs)) - } - while (relatedResourcesQueue.isNotEmpty()) { - val (currentResources, currentRelatedResourceConfigs) = relatedResourcesQueue.removeFirst() - val relatedResourceCountConfigs = - currentRelatedResourceConfigs - ?.asSequence() - ?.filter { it.resultAsCount && !it.searchParameter.isNullOrEmpty() } - ?.toList() - - relatedResourceCountConfigs?.forEach { resourceConfig -> - val search = - Search(resourceConfig.resource).apply { - val filters = - currentResources.map { - val apply: ReferenceParamFilterCriterion.() -> Unit = { - value = it.logicalId.asReference(it.resourceType).reference - } - apply - } - filter( - ReferenceClientParam(resourceConfig.searchParameter), - *filters.toTypedArray(), - ) - applyConfiguredSortAndFilters( - resourceConfig = resourceConfig, - sortData = true, - configComputedRuleValues = configComputedRuleValues, - ) - } - - val key = resourceConfig.id ?: resourceConfig.resource.name - if (resourceConfig.countResultConfig?.sumCounts == true) { - search.count( - onSuccess = { - relatedResourceWrapper.relatedResourceCountMap - .getOrPut(key) { mutableListOf() } - .apply { add(RelatedResourceCount(count = it)) } - }, - onFailure = { - Timber.e( - it, - "Error retrieving total count for all related resources identified by $key", - ) - }, - ) - } else { - computeCountForEachRelatedResource( - resources = currentResources, - resourceConfig = resourceConfig, - relatedResourceWrapper = relatedResourceWrapper, - configComputedRuleValues = configComputedRuleValues, - ) - } - } - - val searchResults = - searchIncludedResources( - relatedResourcesConfigs = currentRelatedResourceConfigs, - resources = currentResources, - configComputedRuleValues = configComputedRuleValues, - ) - - val fwdIncludedRelatedConfigsMap = - currentRelatedResourceConfigs - ?.revIncludeRelatedResourceConfigs(false) - ?.groupBy { it.searchParameter!! } - ?.mapValues { it.value.first() } - - val revIncludedRelatedConfigsMap = - currentRelatedResourceConfigs - ?.revIncludeRelatedResourceConfigs(true) - ?.groupBy { "${it.resource.name}_${it.searchParameter}".lowercase() } - ?.mapValues { it.value.first() } - - searchResults.forEach { searchResult -> - searchResult.included?.forEach { entry -> - updateResourceWrapperAndQueue( - key = entry.key, - defaultKey = entry.value.firstOrNull()?.resourceType?.name, - resources = entry.value, - relatedResourcesConfigsMap = fwdIncludedRelatedConfigsMap, - relatedResourceWrapper = relatedResourceWrapper, - relatedResourcesQueue = relatedResourcesQueue, - ) - } - searchResult.revIncluded?.forEach { entry -> - val (resourceType, searchParam) = entry.key - val key = "${resourceType.name}_$searchParam".lowercase() - updateResourceWrapperAndQueue( - key = key, - defaultKey = entry.value.firstOrNull()?.resourceType?.name, - resources = entry.value, - relatedResourcesConfigsMap = revIncludedRelatedConfigsMap, - relatedResourceWrapper = relatedResourceWrapper, - relatedResourcesQueue = relatedResourcesQueue, - ) - } - } - } - return relatedResourceWrapper - } - private suspend fun computeCountForEachRelatedResource( resources: List, resourceConfig: ResourceConfig, - relatedResourceWrapper: RelatedResourceWrapper, configComputedRuleValues: Map, + repositoryResourceData: RepositoryResourceData, ) { - val relatedResourceCountLinkedList = LinkedList() - val key = resourceConfig.id ?: resourceConfig.resource.name - resources.forEach { baseResource -> - val search = - Search(type = resourceConfig.resource).apply { + val relatedResourceCountList = LinkedList() + resources.forEach { resource -> + val countSearch = + Search(resourceConfig.resource).apply { filter( ReferenceClientParam(resourceConfig.searchParameter), - { value = baseResource.logicalId.asReference(baseResource.resourceType).reference }, + { value = resource.logicalId.asReference(resource.resourceType).reference }, ) applyConfiguredSortAndFilters( resourceConfig = resourceConfig, @@ -606,55 +502,28 @@ constructor( configComputedRuleValues = configComputedRuleValues, ) } - search.count( + countSearch.count( onSuccess = { - relatedResourceCountLinkedList.add( + relatedResourceCountList.add( RelatedResourceCount( relatedResourceType = resourceConfig.resource, - parentResourceId = baseResource.logicalId, + parentResourceId = resource.logicalId, count = it, ), ) }, - onFailure = { + onFailure = { throwable -> Timber.e( - it, - "Error retrieving count for ${ - baseResource.logicalId.asReference( - baseResource.resourceType, - ) - } for related resource identified ID $key", + throwable, + "Error retrieving count for ${resource.asReference().reference} for related resource identified ID ${resourceConfig.id ?: resourceConfig.resource.name}", ) }, ) } - // Add each related resource count query result to map - relatedResourceWrapper.relatedResourceCountMap[key] = relatedResourceCountLinkedList - } - - private fun updateResourceWrapperAndQueue( - key: String, - defaultKey: String?, - resources: List, - relatedResourcesConfigsMap: Map?, - relatedResourceWrapper: RelatedResourceWrapper, - relatedResourcesQueue: ArrayDeque, List?>>, - ) { - val resourceConfigs = relatedResourcesConfigsMap?.get(key) - val id = resourceConfigs?.id ?: defaultKey - if (!id.isNullOrBlank()) { - relatedResourceWrapper.relatedResourceMap[id] = - relatedResourceWrapper.relatedResourceMap - .getOrPut(id) { mutableListOf() } - .apply { addAll(resources.distinctBy { it.logicalId }) } - resources.chunked(DEFAULT_BATCH_SIZE) { item -> - with(resourceConfigs?.relatedResources) { - if (!this.isNullOrEmpty()) { - relatedResourcesQueue.addLast(Pair(item, this)) - } - } - } + if (relatedResourceCountList.isNotEmpty()) { + val key = resourceConfig.id ?: resourceConfig.resource.name + repositoryResourceData.relatedResourcesCountMap.apply { put(key, relatedResourceCountList) } } } @@ -673,74 +542,6 @@ constructor( .onFailure { throwable -> onFailure(throwable) } .getOrDefault(0) - /** - * This function searches for reverse/forward included resources as per the configuration; - * [RelatedResourceWrapper] data class is then used to wrap the maps used to store Search Query - * results. The [relatedResourcesConfigs] configures which resources to load. - */ - private suspend fun searchIncludedResources( - relatedResourcesConfigs: List?, - resources: List, - configComputedRuleValues: Map, - ): List> { - val search = - Search(resources.first().resourceType).apply { - val filters = - resources.map { - val apply: TokenParamFilterCriterion.() -> Unit = { value = of(it.logicalId) } - apply - } - filter(Resource.RES_ID, *filters.toTypedArray()) - } - - // Forward include related resources e.g. a member or managingEntity of a Group resource - val forwardIncludeResourceConfigs = - relatedResourcesConfigs?.revIncludeRelatedResourceConfigs(false) - - // Reverse include related resources e.g. all CarePlans, Immunizations for Patient resource - val reverseIncludeResourceConfigs = - relatedResourcesConfigs?.revIncludeRelatedResourceConfigs(true) - - search.apply { - reverseIncludeResourceConfigs?.forEach { resourceConfig -> - revInclude( - resourceConfig.resource, - ReferenceClientParam(resourceConfig.searchParameter), - ) { - (this as Search).applyConfiguredSortAndFilters( - resourceConfig = resourceConfig, - sortData = true, - configComputedRuleValues = configComputedRuleValues, - ) - } - } - - forwardIncludeResourceConfigs?.forEach { resourceConfig -> - include( - resourceConfig.resource, - ReferenceClientParam(resourceConfig.searchParameter), - ) { - (this as Search).applyConfiguredSortAndFilters( - resourceConfig = resourceConfig, - sortData = true, - configComputedRuleValues = configComputedRuleValues, - ) - } - } - } - return kotlin - .runCatching { fhirEngine.batchedSearch(search) } - .onFailure { Timber.e(it, "Error fetching related resources") } - .getOrDefault(emptyList()) - } - - private fun List.revIncludeRelatedResourceConfigs(isRevInclude: Boolean) = - if (isRevInclude) { - this.filter { it.isRevInclude && !it.resultAsCount } - } else { - this.filter { !it.isRevInclude && !it.resultAsCount } - } - /** * Data queries for retrieving resources require the id to be provided in the format * [ResourceType/UUID] e.g Group/0acda8c9-3fa3-40ae-abcd-7d1fba7098b4. When resources are synced @@ -755,46 +556,42 @@ constructor( ) { withContext(dispatcherProvider.io()) { val configRules = configRulesExecutor.generateRules(resourceConfig.configRules ?: listOf()) - val computedValuesMap = - configRulesExecutor.fireRules(rules = configRules, baseResource = subject).mapValues { - entry, - -> - val initialValue = entry.value.toString() - if (initialValue.contains('/')) { - """${initialValue.substringBefore("/")}/${initialValue.extractLogicalIdUuid()}""" - } else { - initialValue + val configComputedRuleValues = + configRulesExecutor + .computeConfigRules(rules = configRules, baseResource = subject) + .mapValues { entry, + -> + val initialValue = entry.value.toString() + if (initialValue.contains('/')) { + """${initialValue.substringBefore("/")}/${initialValue.extractLogicalIdUuid()}""" + } else { + initialValue + } } - } - val search = - Search(resourceConfig.resource).apply { - applyConfiguredSortAndFilters( - resourceConfig = resourceConfig, - sortData = false, - filterActiveResources = null, - configComputedRuleValues = computedValuesMap, - ) - } - val resources = fhirEngine.batchedSearch(search).map { it.resource } - val filteredResources = - filterResourcesByFhirPathExpression( - resourceFilterExpressions = eventWorkflow.resourceFilterExpressions, - resources = resources, + val repositoryResourceDataList = + searchNestedResources( + baseResourceIds = null, + fhirResourceConfig = FhirResourceConfig(resourceConfig, resourceConfig.relatedResources), + configComputedRuleValues = configComputedRuleValues, + activeResourceFilters = null, + filterByRelatedEntityLocationMetaTag = false, + currentPage = null, + pageSize = null, ) - filteredResources.forEach { - Timber.i("Closing Resource type ${it.resourceType.name} and id ${it.id}") - closeResource(resource = it, eventWorkflow = eventWorkflow) - } - resources.forEach { resource -> - val retrievedRelatedResources = - retrieveRelatedResources( - resource = resource, - relatedResourcesConfigs = resourceConfig.relatedResources, - configComputedRuleValues = computedValuesMap, + repositoryResourceDataList.forEach { entry -> + val repoResourceData = entry.value + filterResourcesByFhirPathExpression( + resourceFilterExpressions = eventWorkflow.resourceFilterExpressions, + resources = listOf(repoResourceData.resource), ) - retrievedRelatedResources.relatedResourceMap.forEach { resourcesMap -> + .forEach { + Timber.i("Closing Resource type ${it.resourceType.name} and id ${it.id}") + closeResource(resource = it, eventWorkflow = eventWorkflow) + } + + repoResourceData.relatedResourcesMap.forEach { resourcesMap -> val filteredRelatedResources = filterResourcesByFhirPathExpression( resourceFilterExpressions = eventWorkflow.resourceFilterExpressions, @@ -811,6 +608,7 @@ constructor( } } } + repositoryResourceDataList.clear() } } @@ -829,24 +627,20 @@ constructor( resourceFilterExpressions: List?, resources: List, ): List { - val resourceFilterExpressionForCurrentResourceType = + val resourceFilterExpression = resourceFilterExpressions?.firstOrNull { - resources.isNotEmpty() && (resources[0].resourceType == it.resourceType) + it.resourceType == resources.firstOrNull()?.resourceType } - return with(resourceFilterExpressionForCurrentResourceType) { - if ((this == null) || conditionalFhirPathExpressions.isEmpty()) { - resources + if (resourceFilterExpression == null) return resources + val (conditionalFhirPathExpressions, matchAll, _) = resourceFilterExpression + return resources.filter { resource -> + if (matchAll) { + conditionalFhirPathExpressions.all { + fhirPathDataExtractor.extractValue(resource, it).toBoolean() + } } else { - resources.filter { resource -> - if (matchAll) { - conditionalFhirPathExpressions.all { - fhirPathDataExtractor.extractValue(resource, it).toBoolean() - } - } else { - conditionalFhirPathExpressions.any { - fhirPathDataExtractor.extractValue(resource, it).toBoolean() - } - } + conditionalFhirPathExpressions.any { + fhirPathDataExtractor.extractValue(resource, it).toBoolean() } } } @@ -944,251 +738,421 @@ constructor( } } - suspend fun countResources( - filterByRelatedEntityLocation: Boolean, - baseResourceConfig: ResourceConfig, - filterActiveResources: List, + suspend fun searchNestedResources( + baseResourceIds: List?, + fhirResourceConfig: FhirResourceConfig, configComputedRuleValues: Map, - ) = - if (filterByRelatedEntityLocation) { - val syncLocationIds = - context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.FILTER_DATA).map { - it.locationId - } - - val locationIds = - syncLocationIds - .map { retrieveFlattenedSubLocations(it).map { subLocation -> subLocation.logicalId } } - .asSequence() - .flatten() - .toHashSet() - val countSearch = - Search(baseResourceConfig.resource).apply { - applyConfiguredSortAndFilters( - resourceConfig = baseResourceConfig, - sortData = false, - filterActiveResources = filterActiveResources, + activeResourceFilters: List?, + filterByRelatedEntityLocationMetaTag: Boolean, + currentPage: Int?, + pageSize: Int?, + ): MutableMap { + val resultsDataMap = mutableMapOf() + if (filterByRelatedEntityLocationMetaTag) { + val locationIds = retrieveRelatedEntitySyncLocationIds() + if (currentPage != null && pageSize != null) { + for (ids in locationIds) { + if (resultsDataMap.size == pageSize) return resultsDataMap + val searchResults = + searchResources( + baseResourceIds = ids, + baseResourceConfig = fhirResourceConfig.baseResource, + relatedResourcesConfigs = fhirResourceConfig.relatedResources, + activeResourceFilters = activeResourceFilters, + configComputedRuleValues = configComputedRuleValues, + currentPage = currentPage, + pageSize = pageSize, + relTagCodeSystem = + context.getString(R.string.sync_strategy_related_entity_location_system), + ) + processSearchResult( + searchResults = searchResults, + resultsDataMap = resultsDataMap, + fhirResourceConfig = fhirResourceConfig, configComputedRuleValues = configComputedRuleValues, + activeResourceFilters = activeResourceFilters, + pageSizeLimit = pageSize, ) } - val totalCount = fhirEngine.count(countSearch) - var searchResultsCount = 0L - var pageNumber = 0 - var count = 0 - while (count < totalCount) { - val baseResourceSearch = - createSearch( - baseResourceConfig = baseResourceConfig, - filterActiveResources = filterActiveResources, + } else { + for (ids in locationIds) { + val searchResults = + searchResources( + baseResourceIds = ids, + baseResourceConfig = fhirResourceConfig.baseResource, + relatedResourcesConfigs = fhirResourceConfig.relatedResources, + activeResourceFilters = activeResourceFilters, + configComputedRuleValues = configComputedRuleValues, + currentPage = null, + pageSize = null, + relTagCodeSystem = + context.getString(R.string.sync_strategy_related_entity_location_system), + ) + processSearchResult( + searchResults = searchResults, + resultsDataMap = resultsDataMap, + fhirResourceConfig = fhirResourceConfig, configComputedRuleValues = configComputedRuleValues, - currentPage = pageNumber, - count = DEFAULT_BATCH_SIZE, + activeResourceFilters = activeResourceFilters, + pageSizeLimit = null, ) - searchResultsCount += - fhirEngine - .search(baseResourceSearch) - .asSequence() - .map { it.resource } - .filter { resource -> - when (resource.resourceType) { - ResourceType.Location -> locationIds.contains(resource.logicalId) - else -> - resource.meta.tag.any { - it.system == - context.getString(R.string.sync_strategy_related_entity_location_system) && - locationIds.contains(it.code) - } - } - } - .count() - .toLong() - count += DEFAULT_BATCH_SIZE - pageNumber++ + } } - searchResultsCount } else { - val search = - Search(baseResourceConfig.resource).apply { - applyConfiguredSortAndFilters( - resourceConfig = baseResourceConfig, - sortData = false, - filterActiveResources = filterActiveResources, - configComputedRuleValues = configComputedRuleValues, - ) - } - search.count( - onFailure = { - Timber.e(it, "Error counting resources ${baseResourceConfig.resource.name}") - }, + val searchResults = + searchResources( + baseResourceIds = baseResourceIds, + baseResourceConfig = fhirResourceConfig.baseResource, + relatedResourcesConfigs = fhirResourceConfig.relatedResources, + activeResourceFilters = activeResourceFilters, + configComputedRuleValues = configComputedRuleValues, + currentPage = currentPage, + pageSize = pageSize, + ) + processSearchResult( + searchResults = searchResults, + resultsDataMap = resultsDataMap, + fhirResourceConfig = fhirResourceConfig, + configComputedRuleValues = configComputedRuleValues, + activeResourceFilters = activeResourceFilters, ) } + return resultsDataMap + } - suspend fun searchResourcesRecursively( - filterByRelatedEntityLocationMetaTag: Boolean, - filterActiveResources: List?, + private suspend fun processSearchResult( + searchResults: List>, + resultsDataMap: MutableMap, fhirResourceConfig: FhirResourceConfig, - secondaryResourceConfigs: List?, - currentPage: Int? = null, - pageSize: Int? = null, - configRules: List?, - ): List { - return withContext(dispatcherProvider.io()) { - val baseResourceConfig = fhirResourceConfig.baseResource - val relatedResourcesConfig = fhirResourceConfig.relatedResources - val configComputedRuleValues = configRules.configRulesComputedValues() - - if (filterByRelatedEntityLocationMetaTag) { - val syncLocationIds = - context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.FILTER_DATA).map { - it.locationId - } - val locationIds = - syncLocationIds - .map { retrieveFlattenedSubLocations(it).map { subLocation -> subLocation.logicalId } } - .flatten() - .toHashSet() - val countSearch = - Search(baseResourceConfig.resource).apply { - applyConfiguredSortAndFilters( - resourceConfig = baseResourceConfig, - sortData = false, - filterActiveResources = filterActiveResources, - configComputedRuleValues = configComputedRuleValues, - ) - } - val totalCount = fhirEngine.count(countSearch) - val searchResults = ArrayDeque>() - var pageNumber = 0 - var count = 0 - while (count < totalCount) { - val baseResourceSearch = - createSearch( - baseResourceConfig = baseResourceConfig, - filterActiveResources = filterActiveResources, - configComputedRuleValues = configComputedRuleValues, - currentPage = pageNumber, - count = DEFAULT_BATCH_SIZE, - ) - val result = fhirEngine.batchedSearch(baseResourceSearch) - searchResults.addAll( - result.filter { searchResult -> - when (baseResourceConfig.resource) { - ResourceType.Location -> locationIds.contains(searchResult.resource.logicalId) - else -> - searchResult.resource.meta.tag.any { - it.system == - context.getString(R.string.sync_strategy_related_entity_location_system) && - locationIds.contains(it.code) - } - } - }, + configComputedRuleValues: Map, + activeResourceFilters: List?, + pageSizeLimit: Int? = null, + ) { + val processedSearchResults = + handleSearchResults( + searchResults = searchResults, + repositoryResourceDataResultMap = resultsDataMap, + repositoryResourceDataMap = null, + relatedResourceConfigs = fhirResourceConfig.relatedResources, + baseResourceConfigId = fhirResourceConfig.baseResource.id, + configComputedRuleValues = configComputedRuleValues, + limit = pageSizeLimit, + ) + + while (processedSearchResults.isNotEmpty()) { + val (newBaseResourceIds, newResourceConfig, repositoryResourceDataMap) = + processedSearchResults.removeFirst() + val newSearchResults = + searchResources( + baseResourceIds = newBaseResourceIds, + baseResourceConfig = newResourceConfig, + relatedResourcesConfigs = newResourceConfig.relatedResources, + activeResourceFilters = activeResourceFilters, + configComputedRuleValues = configComputedRuleValues, + currentPage = null, + pageSize = null, + ) + + val newProcessedSearchResults = + handleSearchResults( + searchResults = newSearchResults, + repositoryResourceDataResultMap = resultsDataMap, + repositoryResourceDataMap = repositoryResourceDataMap, + relatedResourceConfigs = newResourceConfig.relatedResources, + baseResourceConfigId = fhirResourceConfig.baseResource.id, + configComputedRuleValues = configComputedRuleValues, + limit = null, + ) + processedSearchResults.addAll(newProcessedSearchResults) + } + } + + private suspend fun searchResources( + baseResourceIds: List?, + baseResourceConfig: ResourceConfig, + relatedResourcesConfigs: List, + activeResourceFilters: List?, + configComputedRuleValues: Map, + relTagCodeSystem: String? = null, + currentPage: Int?, + pageSize: Int?, + ): List> { + val search = + createSearch( + baseResourceIds = baseResourceIds, + baseResourceConfig = baseResourceConfig, + filterActiveResources = activeResourceFilters, + configComputedRuleValues = configComputedRuleValues, + sortData = true, + currentPage = currentPage, + count = pageSize, + relTagCodeSystem = relTagCodeSystem, + ) + + val (forwardIncludes, reverseIncludes) = + relatedResourcesConfigs.filter { !it.resultAsCount }.partition { !it.isRevInclude } + + search.apply { + reverseIncludes.forEach { resourceConfig -> + revInclude( + resourceConfig.resource, + ReferenceClientParam(resourceConfig.searchParameter), + ) { + (this as Search).applyConfiguredSortAndFilters( + resourceConfig = resourceConfig, + sortData = true, + configComputedRuleValues = configComputedRuleValues, + ) + } + } + forwardIncludes.forEach { resourceConfig -> + include( + resourceConfig.resource, + ReferenceClientParam(resourceConfig.searchParameter), + ) { + (this as Search).applyConfiguredSortAndFilters( + resourceConfig = resourceConfig, + sortData = true, + configComputedRuleValues = configComputedRuleValues, ) - count += DEFAULT_BATCH_SIZE - pageNumber++ - if (currentPage != null && pageSize != null) { - val maxPageCount = (currentPage + 1) * pageSize - if (searchResults.size >= maxPageCount) break - } } + } + } + return fhirEngine.batchedSearch(search) + } - if (currentPage != null && pageSize != null) { - val fromIndex = currentPage * pageSize - val toIndex = (currentPage + 1) * pageSize - val maxSublistIndex = min(toIndex, searchResults.size) + private suspend fun handleSearchResults( + searchResults: List>, + repositoryResourceDataResultMap: MutableMap, + repositoryResourceDataMap: Map?, + relatedResourceConfigs: List, + baseResourceConfigId: String?, + configComputedRuleValues: Map, + limit: Int?, + ): SearchQueryResultQueue { + val relatedResourcesQueue = RelatedResourcesQueue() + val (forwardIncludes, reverseIncludes) = + relatedResourceConfigs + .asSequence() + .filter { !it.resultAsCount } + .partition { !it.isRevInclude } + val forwardIncludesMap = + forwardIncludes.groupBy { it.searchParameter!! }.mapValues { it.value.first() } + val reverseIncludesMap = + reverseIncludes + .groupBy { "${it.resource.name}_${it.searchParameter}".lowercase() } + .mapValues { it.value.first() } + + searchResults.forEach { searchResult: SearchResult -> + // Create new repository data if none exist (subsequent queries will have repository data) + // First get the key for the repository data, then proceed to retrieve it from the result map + val repositoryResourceDataMapId = + repositoryResourceDataMap?.get(searchResult.resource.logicalId) + val repositoryResourceData = + repositoryResourceDataResultMap[repositoryResourceDataMapId] + ?: RepositoryResourceData( + resource = searchResult.resource, + resourceConfigId = baseResourceConfigId, + ) - if (fromIndex < maxSublistIndex) { - with(searchResults.subList(fromIndex, maxSublistIndex)) { - mapResourceToRepositoryResourceData( - relatedResourcesConfig = relatedResourcesConfig, - configComputedRuleValues = configComputedRuleValues, - secondaryResourceConfigs = secondaryResourceConfigs, - filterActiveResources = filterActiveResources, - baseResourceConfig = baseResourceConfig, - ) - } - } else { - emptyList() - } - } else { - searchResults.mapResourceToRepositoryResourceData( - relatedResourcesConfig = relatedResourcesConfig, + searchResult.included?.forEach { entry -> + // Add the forward included resources to the relatedResourcesMap + val fwdIncludedResourceConfig = forwardIncludesMap[entry.key] + updateRepositoryResourceData( + resources = entry.value, + relatedResourceConfig = fwdIncludedResourceConfig, + repositoryResourceData = repositoryResourceData, + relatedResourcesQueue = relatedResourcesQueue, + ) + if (entry.value.isNotEmpty() && fwdIncludedResourceConfig != null) { + handleCountResults( + resources = entry.value, + repositoryResourceData = repositoryResourceData, + countConfigs = extractCountConfigs(fwdIncludedResourceConfig), configComputedRuleValues = configComputedRuleValues, - secondaryResourceConfigs = secondaryResourceConfigs, - filterActiveResources = filterActiveResources, - baseResourceConfig = baseResourceConfig, ) } - } else { - val baseFhirResources: List> = - kotlin - .runCatching { - val search = - createSearch( - baseResourceConfig = baseResourceConfig, - filterActiveResources = filterActiveResources, - configComputedRuleValues = configComputedRuleValues, - currentPage = currentPage, - count = pageSize, - ) - fhirEngine.batchedSearch(search) - } - .onFailure { - Timber.e( - t = it, - message = "Error retrieving resources. Empty list returned by default", - ) - } - .getOrDefault(emptyList()) - baseFhirResources.mapResourceToRepositoryResourceData( - relatedResourcesConfig = relatedResourcesConfig, - configComputedRuleValues = configComputedRuleValues, - secondaryResourceConfigs = secondaryResourceConfigs, - filterActiveResources = filterActiveResources, - baseResourceConfig = baseResourceConfig, + } + searchResult.revIncluded?.forEach { entry -> + val (resourceType, searchParam) = entry.key + val name = "${resourceType.name}_$searchParam".lowercase() + val revIncludedResourceConfig = reverseIncludesMap[name] + // Add the reverse included resources to the relatedResourcesMap + updateRepositoryResourceData( + resources = entry.value, + relatedResourceConfig = revIncludedResourceConfig, + repositoryResourceData = repositoryResourceData, + relatedResourcesQueue = relatedResourcesQueue, + ) + if (entry.value.isNotEmpty() && revIncludedResourceConfig != null) { + handleCountResults( + resources = entry.value, + repositoryResourceData = repositoryResourceData, + countConfigs = extractCountConfigs(revIncludedResourceConfig), + configComputedRuleValues = configComputedRuleValues, + ) + } + } + if ( + repositoryResourceDataMap == null && + (limit == null || repositoryResourceDataResultMap.size < limit) + ) { + repositoryResourceDataResultMap[searchResult.resource.logicalId] = repositoryResourceData + } + } + return groupAndBatchQueriedResources(relatedResourcesQueue) + } + + private fun extractCountConfigs(relatedResourceConfig: ResourceConfig) = + relatedResourceConfig.relatedResources + .filter { it.resultAsCount && !it.searchParameter.isNullOrEmpty() } + .toList() + + private fun updateRepositoryResourceData( + resources: List, + relatedResourceConfig: ResourceConfig?, + repositoryResourceData: RepositoryResourceData?, + relatedResourcesQueue: RelatedResourcesQueue, + ) { + if (resources.isNotEmpty() && repositoryResourceData != null) { + val key = relatedResourceConfig?.id ?: relatedResourceConfig?.resource?.name + if (!key.isNullOrBlank()) { + repositoryResourceData.apply { + relatedResourcesMap + .getOrPut(key = key) { mutableListOf() } + .apply { (this as MutableList).addAll(resources) } + } + } + + val hasRelatedResources = relatedResourceConfig?.relatedResources?.any { !it.resultAsCount } + if (hasRelatedResources == true) { + // Track the next nested resource to be fetched. ID for base resources is the unique key + relatedResourcesQueue.addLast( + Triple( + first = resources.mapTo(HashSet()) { it.logicalId }, + second = relatedResourceConfig, + third = repositoryResourceData.resource.logicalId, // The key to the result data map + ), ) } - as List } } - private suspend fun List>.mapResourceToRepositoryResourceData( - relatedResourcesConfig: List, + /** + * Count the related resources that references the provided [resources]. The count is updated in + * the [repositoryResourceData]. + */ + private suspend fun handleCountResults( + resources: List, + repositoryResourceData: RepositoryResourceData, + countConfigs: List, configComputedRuleValues: Map, - secondaryResourceConfigs: List?, - filterActiveResources: List?, - baseResourceConfig: ResourceConfig, - ) = - this.pmap { searchResult -> - val retrievedRelatedResources = - retrieveRelatedResources( - resource = searchResult.resource, - relatedResourcesConfigs = relatedResourcesConfig, + ) { + if (countConfigs.isEmpty()) return + resources.chunked(RESOURCE_BATCH_SIZE).forEach { theResources -> + countRelatedResources( + resources = theResources, + repositoryResourceData = repositoryResourceData, + countConfigs = countConfigs, + configComputedRuleValues = configComputedRuleValues, + ) + } + } + + private suspend fun countRelatedResources( + resources: List, + repositoryResourceData: RepositoryResourceData, + countConfigs: List, + configComputedRuleValues: Map, + ) { + countConfigs.forEach { resourceConfig -> + if (resourceConfig.countResultConfig?.sumCounts == true) { + // Count all the related resources. E.g. count all members (Patient) of household (Group) + val countSearch = + Search(resourceConfig.resource).apply { + val filters = + resources.map { + val apply: ReferenceParamFilterCriterion.() -> Unit = { + value = it.logicalId.asReference(it.resourceType).reference + } + apply + } + filter( + ReferenceClientParam(resourceConfig.searchParameter), + *filters.toTypedArray(), + ) + applyConfiguredSortAndFilters( + resourceConfig = resourceConfig, + sortData = false, + configComputedRuleValues = configComputedRuleValues, + ) + } + val key = resourceConfig.id ?: resourceConfig.resource.name + countSearch.count( + onSuccess = { + repositoryResourceData.apply { + relatedResourcesCountMap + .getOrPut(key) { mutableListOf() } + .apply { + (this as MutableList).add( + RelatedResourceCount( + count = it, + relatedResourceType = resourceConfig.resource, + ), + ) + } + } + }, + onFailure = { + Timber.e( + it, + "Error retrieving total count for all related resources identified by $key", + ) + }, + ) + } else { + // Count each related resources, e.g. number of visits (Encounter) for every Patient + computeCountForEachRelatedResource( + resources = resources, + resourceConfig = resourceConfig, configComputedRuleValues = configComputedRuleValues, + repositoryResourceData = repositoryResourceData, ) - val secondaryRepositoryResourceData = - secondaryResourceConfigs.retrieveSecondaryRepositoryResourceData(filterActiveResources) - RepositoryResourceData( - resourceRulesEngineFactId = baseResourceConfig.id ?: baseResourceConfig.resource.name, - resource = searchResult.resource, - relatedResourcesMap = retrievedRelatedResources.relatedResourceMap, - relatedResourcesCountMap = retrievedRelatedResources.relatedResourceCountMap, - secondaryRepositoryResourceData = secondaryRepositoryResourceData, - ) + } } + } protected fun createSearch( + baseResourceIds: List? = null, baseResourceConfig: ResourceConfig, filterActiveResources: List?, configComputedRuleValues: Map, + sortData: Boolean, currentPage: Int?, count: Int?, + relTagCodeSystem: String?, ): Search { val search = Search(type = baseResourceConfig.resource).apply { + if (!baseResourceIds.isNullOrEmpty()) { + when (baseResourceConfig.resource) { + ResourceType.Location -> + filter(Resource.RES_ID, *createFilters(baseResourceIds, null).toTypedArray()) + else -> + if (relTagCodeSystem.isNullOrBlank()) { + filter(Resource.RES_ID, *createFilters(baseResourceIds, null).toTypedArray()) + } else { + filter( + TokenClientParam(TAG), + *createFilters(baseResourceIds, relTagCodeSystem).toTypedArray(), + ) + } + } + } applyConfiguredSortAndFilters( resourceConfig = baseResourceConfig, filterActiveResources = filterActiveResources, - sortData = true, + sortData = sortData, configComputedRuleValues = configComputedRuleValues, ) if (currentPage != null && count != null) { @@ -1199,29 +1163,94 @@ constructor( return search } - protected fun List?.configRulesComputedValues(): Map { - if (this == null) return emptyMap() - val configRules = configRulesExecutor.generateRules(this) - return configRulesExecutor.fireRules(configRules) + private fun createFilters( + baseResourceIds: List, + relTagCodeSystem: String? = null, + ): List Unit> { + val filters = + baseResourceIds.map { + val apply: TokenParamFilterCriterion.() -> Unit = { + value = of(Coding(relTagCodeSystem, it, null)) + } + apply + } + return filters } - /** This function fetches other resources that are not linked to the base/primary resource. */ - protected suspend fun List?.retrieveSecondaryRepositoryResourceData( - filterActiveResources: List?, - ): List { - val secondaryRepositoryResourceDataList = mutableListOf() - this?.forEach { - secondaryRepositoryResourceDataList.addAll( - searchResourcesRecursively( - fhirResourceConfig = it, - filterActiveResources = filterActiveResources, - secondaryResourceConfigs = null, - configRules = null, - filterByRelatedEntityLocationMetaTag = false, - ), - ) + /** + * Groups resources by their [ResourceConfig] and batches them into groups of up to a specified + * size. + * + * This function combines resources across multiple triples with the same [ResourceConfig], + * ensuring all resources are grouped together while maintaining their association with the + * respective [RepositoryResourceData]. Each batch contains a mapping of resource logicalId to + * their corresponding [RepositoryResourceData], allowing traceability. + * + * @param relatedResourcesQueue An [ArrayDeque] containing triples of: + * - A list of [Resource.logicalId]s to be grouped and batched. + * - A [ResourceConfig] shared by all resources in the triple. + * - A [RepositoryResourceData] shared by all resources in the triple. + * + * @param batchSize The maximum number of resources in each batch. Must be greater than 0. + * @return A [ArrayDeque] of triples where each triple contains: + * - A list of [Resource.logicalId]s grouped into batches of up to [batchSize]. + * - The [ResourceConfig] shared by the batch. + * - A map of [Resource.logicalId] to its corresponding [RepositoryResourceData], ensuring + * traceability. + * + * @throws IllegalArgumentException if [batchSize] is less than or equal to 0. + * + * ``` + */ + private fun groupAndBatchQueriedResources( + relatedResourcesQueue: RelatedResourcesQueue, + batchSize: Int = RESOURCE_BATCH_SIZE, + ): SearchQueryResultQueue { + require(batchSize > 0) { "Batch size must be greater than 0" } + if (relatedResourcesQueue.isEmpty()) return SearchQueryResultQueue() + val resultQueue = SearchQueryResultQueue() + val bufferMap = mutableMapOf>>() + + while (relatedResourcesQueue.isNotEmpty()) { + val (resourceIds, config, data) = relatedResourcesQueue.removeFirst() + val buffer = bufferMap.getOrPut(config) { ArrayDeque() } + + resourceIds.forEach { id -> buffer.add(id to data) } + + // Create and add batches to the result queue + while (buffer.size >= batchSize) { + val batch = List(batchSize) { buffer.removeFirst() } + resultQueue.addLast( + Triple( + first = batch.map { it.first }, + second = config, + third = batch.associate { it.first to it.second }, + ), + ) + } + } + + // Add any remaining items in the buffers + for ((config, buffer) in bufferMap) { + if (buffer.isNotEmpty()) { + val batch = buffer.toList() + resultQueue.addLast( + Triple( + first = batch.map { it.first }, + second = config, + third = batch.associate { it.first to it.second }, + ), + ) + buffer.clear() + } } - return secondaryRepositoryResourceDataList + return resultQueue + } + + protected fun List?.configRulesComputedValues(): Map { + if (this == null) return emptyMap() + val rules = configRulesExecutor.generateRules(this) + return configRulesExecutor.computeConfigRules(rules = rules, null) } suspend fun retrieveUniqueIdAssignmentResource( @@ -1268,42 +1297,92 @@ constructor( return null } - suspend fun retrieveFlattenedSubLocations(locationId: String): ArrayDeque { - val locations = ArrayDeque() - val resources: ArrayDeque = retrieveSubLocations(locationId) - while (resources.isNotEmpty()) { - val currentResource = resources.removeFirst() - locations.add(currentResource) - retrieveSubLocations(currentResource.logicalId).forEach(resources::addLast) + protected suspend fun retrieveRelatedEntitySyncLocationIds(): List> = + withContext(dispatcherProvider.io()) { + context + .retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.FILTER_DATA) + .chunked(SQL_WHERE_CLAUSE_LIMIT) + .map { it.map { state -> state.locationId } } + .flatMap { retrieveFlattenedSubLocationIds(it) } + .chunked(SQL_WHERE_CLAUSE_LIMIT) + } + + suspend fun retrieveFlattenedSubLocationIds(locationIds: List): HashSet { + val locations = HashSet(locationIds) + val queue = ArrayDeque>() + val subLocations = retrieveSubLocations(locationIds) + if (subLocations.isNotEmpty()) { + locations.addAll(subLocations) + queue.add(subLocations) + } + while (queue.isNotEmpty()) { + val newSubLocations = retrieveSubLocations(queue.removeFirst()) + if (newSubLocations.isNotEmpty()) { + locations.addAll(newSubLocations) + queue.add(newSubLocations) + } } - loadResource(locationId)?.let { parentLocation -> locations.addFirst(parentLocation) } return locations } - private suspend fun retrieveSubLocations(locationId: String): ArrayDeque = - fhirEngine - .batchedSearch( - Search(type = ResourceType.Location).apply { - filter( - Location.PARTOF, - { value = locationId.asReference(ResourceType.Location).reference }, - ) - }, - ) - .mapTo(ArrayDeque()) { it.resource } + private suspend fun retrieveSubLocations(locationIds: List): List { + val search = + Search(type = ResourceType.Location).apply { + val filters = createFilters(locationIds) + filter(Resource.RES_ID, *filters.toTypedArray()) + revInclude(Location.PARTOF) + } + return fhirEngine + .search(search) + .flatMap { it.revIncluded?.values?.flatten() ?: emptyList() } + .map { it.logicalId } + } /** - * A wrapper data class to hold search results. All related resources are flattened into one Map - * including the nested related resources as required by the Rules Engine facts. + * This function searches and returns the latest [QuestionnaireResponse] for the given + * [resourceId] that was extracted from the [Questionnaire] identified as [questionnaireId]. + * Returns null if non is found. */ - data class RelatedResourceWrapper( - val relatedResourceMap: MutableMap> = mutableMapOf(), - val relatedResourceCountMap: MutableMap> = - mutableMapOf(), - ) + suspend fun searchQuestionnaireResponse( + resourceId: String, + resourceType: ResourceType, + questionnaireId: String, + encounterId: String?, + questionnaireResponseStatus: String? = null, + ): QuestionnaireResponse? { + val search = + Search(ResourceType.QuestionnaireResponse).apply { + filter( + QuestionnaireResponse.SUBJECT, + { value = resourceId.asReference(resourceType).reference }, + ) + filter( + QuestionnaireResponse.QUESTIONNAIRE, + { value = questionnaireId.asReference(ResourceType.Questionnaire).reference }, + ) + if (!encounterId.isNullOrBlank()) { + filter( + QuestionnaireResponse.ENCOUNTER, + { + value = + encounterId.extractLogicalIdUuid().asReference(ResourceType.Encounter).reference + }, + ) + } + if (!questionnaireResponseStatus.isNullOrBlank()) { + filter( + QuestionnaireResponse.STATUS, + { value = of(questionnaireResponseStatus) }, + ) + } + } + val questionnaireResponses: List = search(search) + return questionnaireResponses.maxByOrNull { it.meta.lastUpdated } + } companion object { - const val DEFAULT_BATCH_SIZE = 250 + const val RESOURCE_BATCH_SIZE = 50 + const val SQL_WHERE_CLAUSE_LIMIT = 200 // Hard limit for WHERE CLAUSE items is 1000 const val SNOMED_SYSTEM = "http://hl7.org/fhir/R4B/valueset-condition-clinical.html" const val PATIENT_CONDITION_RESOLVED_CODE = "resolved" const val PATIENT_CONDITION_RESOLVED_DISPLAY = "Resolved" diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt index af77c12a3f..d769c128e6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt @@ -19,10 +19,11 @@ package org.smartregister.fhircore.engine.data.local.register import android.content.Context import ca.uhn.fhir.parser.IParser import com.google.android.fhir.FhirEngine +import com.google.android.fhir.search.Search import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import kotlinx.coroutines.withContext -import org.hl7.fhir.r4.model.Resource +import javax.inject.Singleton +import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService @@ -30,17 +31,15 @@ import org.smartregister.fhircore.engine.configuration.profile.ProfileConfigurat import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository -import org.smartregister.fhircore.engine.domain.model.ActionParameter -import org.smartregister.fhircore.engine.domain.model.ActionParameterType import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.repository.Repository import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper -import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +@Singleton class RegisterRepository @Inject constructor( @@ -76,16 +75,28 @@ constructor( paramsMap: Map?, ): List { val registerConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) - return searchResourcesRecursively( - filterByRelatedEntityLocationMetaTag = - registerConfiguration.filterDataByRelatedEntityLocation, - filterActiveResources = registerConfiguration.activeResourceFilters, - fhirResourceConfig = fhirResourceConfig ?: registerConfiguration.fhirResource, - secondaryResourceConfigs = registerConfiguration.secondaryResources, - currentPage = currentPage, - pageSize = registerConfiguration.pageSize, - configRules = registerConfiguration.configRules, + val requiredFhirResourceConfig = fhirResourceConfig ?: registerConfiguration.fhirResource + val configComputedRuleValues = registerConfiguration.configRules.configRulesComputedValues() + + val registerDataMap = + searchNestedResources( + baseResourceIds = null, + fhirResourceConfig = requiredFhirResourceConfig, + configComputedRuleValues = configComputedRuleValues, + activeResourceFilters = registerConfiguration.activeResourceFilters, + filterByRelatedEntityLocationMetaTag = + registerConfiguration.filterDataByRelatedEntityLocation, + currentPage = currentPage, + pageSize = registerConfiguration.pageSize, + ) + + populateSecondaryResources( + secondaryResources = registerConfiguration.secondaryResources, + configComputedRuleValues = configComputedRuleValues, + resultsDataMap = registerDataMap, ) + + return registerDataMap.values.toList() } /** Count register data for the provided [registerId]. Use the configured base resource filters */ @@ -94,68 +105,76 @@ constructor( fhirResourceConfig: FhirResourceConfig?, paramsMap: Map?, ): Long { - return withContext(dispatcherProvider.io()) { - val registerConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) - val fhirResource = fhirResourceConfig ?: registerConfiguration.fhirResource - val baseResourceConfig = fhirResource.baseResource - val configComputedRuleValues = registerConfiguration.configRules.configRulesComputedValues() - val filterByRelatedEntityLocation = registerConfiguration.filterDataByRelatedEntityLocation - val filterActiveResources = registerConfiguration.activeResourceFilters - countResources( - filterByRelatedEntityLocation = filterByRelatedEntityLocation, - baseResourceConfig = baseResourceConfig, - filterActiveResources = filterActiveResources, - configComputedRuleValues = configComputedRuleValues, + val registerConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) + val fhirResource = fhirResourceConfig ?: registerConfiguration.fhirResource + val baseResourceConfig = fhirResource.baseResource + val configComputedRuleValues = registerConfiguration.configRules.configRulesComputedValues() + val filterByRelatedEntityLocation = registerConfiguration.filterDataByRelatedEntityLocation + val filterActiveResources = registerConfiguration.activeResourceFilters + + if (!filterByRelatedEntityLocation) { + return fhirEngine.count( + Search(baseResourceConfig.resource).apply { + applyConfiguredSortAndFilters( + resourceConfig = baseResourceConfig, + sortData = false, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + ) + }, ) } + + val locationIds = retrieveRelatedEntitySyncLocationIds() + var total = 0L + for (ids in locationIds) { + val search = + createSearch( + baseResourceIds = ids, + baseResourceConfig = baseResourceConfig, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + sortData = false, + currentPage = null, + count = null, + relTagCodeSystem = + context.getString(R.string.sync_strategy_related_entity_location_system), + ) + total += fhirEngine.count(search) + } + return total } override suspend fun loadProfileData( profileId: String, resourceId: String, fhirResourceConfig: FhirResourceConfig?, - paramsList: Array?, - ): RepositoryResourceData { - return withContext(dispatcherProvider.io()) { - val paramsMap: Map = - paramsList - ?.asSequence() - ?.filter { - (it.paramType == ActionParameterType.PARAMDATA || - it.paramType == ActionParameterType.UPDATE_DATE_ON_EDIT) && it.value.isNotEmpty() - } - ?.associate { it.key to it.value } ?: emptyMap() - - val profileConfiguration = retrieveProfileConfiguration(profileId, paramsMap) - val resourceConfig = fhirResourceConfig ?: profileConfiguration.fhirResource - val baseResourceConfig = resourceConfig.baseResource - - val baseResource: Resource = - fhirEngine.get(baseResourceConfig.resource, resourceId.extractLogicalIdUuid()) - - val configComputedRuleValues = profileConfiguration.configRules.configRulesComputedValues() - - val retrievedRelatedResources = - retrieveRelatedResources( - resource = baseResource, - relatedResourcesConfigs = resourceConfig.relatedResources, - configComputedRuleValues = configComputedRuleValues, - ) + paramsMap: Map?, + ): RepositoryResourceData? { + val profileConfiguration = retrieveProfileConfiguration(profileId, paramsMap) + val requiredFhirResourceConfig = fhirResourceConfig ?: profileConfiguration.fhirResource + val configComputedRuleValues = profileConfiguration.configRules.configRulesComputedValues() - RepositoryResourceData( - resourceRulesEngineFactId = baseResourceConfig.id ?: baseResourceConfig.resource.name, - resource = baseResource, - relatedResourcesMap = retrievedRelatedResources.relatedResourceMap, - relatedResourcesCountMap = retrievedRelatedResources.relatedResourceCountMap, - secondaryRepositoryResourceData = - profileConfiguration.secondaryResources.retrieveSecondaryRepositoryResourceData( - profileConfiguration.filterActiveResources, - ), + val profileDataMap = + searchNestedResources( + baseResourceIds = listOf(resourceId), + fhirResourceConfig = requiredFhirResourceConfig, + configComputedRuleValues = configComputedRuleValues, + activeResourceFilters = null, + filterByRelatedEntityLocationMetaTag = false, + currentPage = null, + pageSize = null, ) - } + + populateSecondaryResources( + secondaryResources = profileConfiguration.secondaryResources, + configComputedRuleValues = configComputedRuleValues, + resultsDataMap = profileDataMap, + ) + return profileDataMap.values.firstOrNull() } - fun retrieveProfileConfiguration(profileId: String, paramsMap: Map) = + fun retrieveProfileConfiguration(profileId: String, paramsMap: Map?) = configurationRegistry.retrieveConfiguration( configType = ConfigType.Profile, configId = profileId, @@ -167,4 +186,36 @@ constructor( paramsMap: Map?, ): RegisterConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, registerId, paramsMap) + + /** + * Retrieve and populate secondary resources in [resultsDataMap]. Every [RepositoryResourceData] + * in [resultsDataMap] must have a copy of the secondary resources. Secondary resources + * independent resources that needs to be loaded and have no relationship with the primary base + * resources. + */ + private suspend fun populateSecondaryResources( + secondaryResources: List?, + configComputedRuleValues: Map, + resultsDataMap: MutableMap, + ) { + if (!secondaryResources.isNullOrEmpty()) { + val secondaryRepositoryResourceData = mutableListOf() + secondaryResources.forEach { secondaryFhirResourceConfig -> + val resultsMap = + searchNestedResources( + baseResourceIds = null, + fhirResourceConfig = secondaryFhirResourceConfig, + configComputedRuleValues = configComputedRuleValues, + activeResourceFilters = null, + filterByRelatedEntityLocationMetaTag = false, + currentPage = null, + pageSize = 1, + ) + secondaryRepositoryResourceData.addAll(resultsMap.values) + } + resultsDataMap.forEach { entry -> + entry.value.secondaryRepositoryResourceData = secondaryRepositoryResourceData + } + } + } } 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 74a925f75f..c0c4007759 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 @@ -35,6 +35,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.apache.commons.jexl3.JexlBuilder +import org.apache.commons.jexl3.JexlEngine import org.hl7.fhir.r4.context.SimpleWorkerContext import org.hl7.fhir.r4.model.Parameters import org.hl7.fhir.r4.model.Resource @@ -127,4 +129,21 @@ class CoreModule { .fhirContext(fhirContext) .knowledgeManager(knowledgeManager) .build() + + @Singleton + @Provides + fun provideJexlEngine(): JexlEngine { + return JexlBuilder() // Expensive initialization + .namespaces( + mutableMapOf( + "Timber" to Timber, + "StringUtils" to Class.forName("org.apache.commons.lang3.StringUtils"), + "RegExUtils" to Class.forName("org.apache.commons.lang3.RegExUtils"), + "Math" to Class.forName("java.lang.Math"), + ), + ) + .silent(false) + .strict(false) + .create() + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/DispatcherModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/DispatcherModule.kt index f31e4283cb..7241080d31 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/DispatcherModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/DispatcherModule.kt @@ -20,6 +20,7 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.DispatcherProvider @@ -27,6 +28,7 @@ import org.smartregister.fhircore.engine.util.DispatcherProvider @Module abstract class DispatcherModule { + @Singleton @Binds abstract fun bindDefaultDispatcherProvider( defaultDispatcherProvider: DefaultDispatcherProvider, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt index 1ce80b7bf7..d48b96221c 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt @@ -57,6 +57,7 @@ import timber.log.Timber class NetworkModule { private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK + @Singleton @Provides @NoAuthorizationOkHttpClientQualifier fun provideAuthOkHttpClient() = @@ -78,6 +79,7 @@ class NetworkModule { .callTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS) .build() + @Singleton @Provides @WithAuthorizationOkHttpClientQualifier fun provideOkHttpClient( @@ -156,6 +158,7 @@ class NetworkModule { .retryOnConnectionFailure(false) // Avoid silent retries sometimes before token is provided .build() + @Singleton @Provides fun provideGson(): Gson = GsonBuilder() @@ -163,7 +166,9 @@ class NetworkModule { .registerTypeAdapter(TimeZone::class.java, TimeZoneTypeAdapter().nullSafe()) .create() - @Provides fun provideParser(): IParser = FhirContext.forR4Cached().getCustomJsonParser() + @Singleton + @Provides + fun provideParser(): IParser = FhirContext.forR4Cached().getCustomJsonParser() @Provides @Singleton @@ -174,6 +179,7 @@ class NetworkModule { useAlternativeNames = true } + @Singleton @Provides @AuthenticationRetrofit fun provideAuthRetrofit( @@ -188,6 +194,7 @@ class NetworkModule { .build() @OptIn(ExperimentalSerializationApi::class) + @Singleton @Provides @KeycloakRetrofit fun provideKeycloakRetrofit( @@ -201,6 +208,7 @@ class NetworkModule { .addConverterFactory(json.asConverterFactory(JSON_MEDIA_TYPE)) .build() + @Singleton @Provides @RegularRetrofit fun provideRegularRetrofit( @@ -216,15 +224,18 @@ class NetworkModule { .addConverterFactory(GsonConverterFactory.create(gson)) .build() + @Singleton @Provides fun provideOauthService( @AuthenticationRetrofit retrofit: Retrofit, ): OAuthService = retrofit.create(OAuthService::class.java) + @Singleton @Provides fun provideKeycloakService(@KeycloakRetrofit retrofit: Retrofit): KeycloakService = retrofit.create(KeycloakService::class.java) + @Singleton @Provides fun provideFhirResourceService(@RegularRetrofit retrofit: Retrofit): FhirResourceService = retrofit.create(FhirResourceService::class.java) 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 3b9f5b3acd..69c53d823b 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 @@ -35,7 +35,7 @@ data class ActionConfig( val workflow: String? = null, val id: String? = null, val display: String? = null, - val rules: List? = null, + val rules: List = emptyList(), val questionnaire: QuestionnaireConfig? = null, val managingEntity: ManagingEntityConfig? = null, val params: List = emptyList(), diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/RepositoryResourceData.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/RepositoryResourceData.kt index 5d2a6f70ff..b396a4fe3b 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/RepositoryResourceData.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/RepositoryResourceData.kt @@ -17,26 +17,27 @@ package org.smartregister.fhircore.engine.domain.model import androidx.compose.runtime.Stable +import java.util.concurrent.ConcurrentHashMap import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType /** * This represent the outcome of a query performed via the Repository. The query performed can * either return a count or map of [Resource]'s (including nested resources flattened in the map). - * The optional property [resourceRulesEngineFactId] that can be used as the key in the rules - * factory facts map (each fact is represented as a key-value pair). The key for the - * [relatedResourcesMap] will either be the configured unique id for representing the resource(s) in - * Rules engine Facts map or the [ResourceType]. [secondaryRepositoryResourceData] returns a list of - * independent resources (which may include nested resource(s)) that have NO relationship with the - * base [resource]. + * The optional property [resourceConfigId] can be used as the key in the rules factory facts map + * (each fact is represented as a key-value pair). The key for the [relatedResourcesMap] will either + * be the configured unique id for representing the resource(s) in Rules engine Facts map or the + * [ResourceType]. [secondaryRepositoryResourceData] returns a list of independent resources (which + * may include nested resource(s)) that have NO relationship with the base [resource]. */ @Stable data class RepositoryResourceData( - val resourceRulesEngineFactId: String? = null, + val resourceConfigId: String? = null, val resource: Resource, - val relatedResourcesMap: Map> = emptyMap(), - val relatedResourcesCountMap: Map> = emptyMap(), - val secondaryRepositoryResourceData: List? = null, + val relatedResourcesMap: ConcurrentHashMap> = ConcurrentHashMap(), + val relatedResourcesCountMap: ConcurrentHashMap> = + ConcurrentHashMap(), + var secondaryRepositoryResourceData: List? = null, ) /** diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/repository/Repository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/repository/Repository.kt index ba01b6a4d1..9951e17505 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/repository/Repository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/repository/Repository.kt @@ -16,10 +16,8 @@ package org.smartregister.fhircore.engine.domain.repository -import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData -import org.smartregister.fhircore.engine.domain.model.ResourceData /** This class provides common functionalities used in the register */ interface Repository { @@ -27,7 +25,7 @@ interface Repository { /** * This function loads the desired register configuration using the provided [registerId]. The * data query extracted from the retrieved configuration is used to filter the register data (FHIR - * resources wrapped in [ResourceData] + * resources wrapped in [RepositoryResourceData] */ suspend fun loadRegisterData( currentPage: Int, @@ -56,6 +54,6 @@ interface Repository { profileId: String, resourceId: String, fhirResourceConfig: FhirResourceConfig? = null, - paramsList: Array?, + paramsMap: Map? = emptyMap(), ): RepositoryResourceData? } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutor.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutor.kt index c03ff0a495..06dc11ee2e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutor.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutor.kt @@ -17,12 +17,16 @@ package org.smartregister.fhircore.engine.rulesengine import javax.inject.Inject +import javax.inject.Singleton import kotlin.system.measureTimeMillis +import org.apache.commons.jexl3.JexlEngine import org.hl7.fhir.r4.model.Resource import org.jeasy.rules.api.Facts import org.jeasy.rules.api.Rules import org.smartregister.fhircore.engine.BuildConfig +import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.rulesengine.services.DateService +import org.smartregister.fhircore.engine.util.extension.generateRules import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import timber.log.Timber @@ -31,16 +35,26 @@ import timber.log.Timber * * NOTE: that the [Facts] object is not thread safe, each thread should have its own set of data to * work on. When used in a multi-threaded environment it may exhibit unexpected behavior and return - * incorrect results when rules are fired. Use the [ResourceDataRulesExecutor] in the same coroutine + * incorrect results when rules are fired. Use the [ConfigRulesExecutor] in the same coroutine * context of the caller. */ -class ConfigRulesExecutor @Inject constructor(val fhirPathDataExtractor: FhirPathDataExtractor) : - RulesListener() { +@Singleton +class ConfigRulesExecutor +@Inject +constructor( + val fhirPathDataExtractor: FhirPathDataExtractor, + val jexlEngine: JexlEngine, +) : RulesListener() { - private var facts: Facts = Facts() + /** Compute configuration level [Rules] */ + fun computeConfigRules(rules: Rules, baseResource: Resource?): Map = + fireRules( + rules = rules, + baseResource = baseResource, + ) - fun fireRules(rules: Rules, baseResource: Resource? = null): Map { - facts = + private fun fireRules(rules: Rules, baseResource: Resource? = null): Map { + val facts = Facts().apply { put(FHIR_PATH, fhirPathDataExtractor) put(DATA, mutableMapOf()) @@ -58,6 +72,8 @@ class ConfigRulesExecutor @Inject constructor(val fhirPathDataExtractor: FhirPat return facts.get(DATA) as Map } + fun generateRules(ruleConfigs: List): Rules = ruleConfigs.generateRules(jexlEngine) + companion object { private const val DATE_SERVICE = "dateService" } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutor.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutor.kt similarity index 92% rename from android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutor.kt rename to android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutor.kt index 7ed10158d1..109343f9e2 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutor.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutor.kt @@ -20,9 +20,12 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateMap import com.google.android.fhir.datacapture.extensions.logicalId +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +import javax.inject.Singleton import org.hl7.fhir.r4.model.Resource import org.jeasy.rules.api.Facts +import org.jeasy.rules.api.Rules import org.smartregister.fhircore.engine.configuration.view.ListProperties import org.smartregister.fhircore.engine.configuration.view.ListResourceConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData @@ -33,23 +36,24 @@ import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.interpolate /** - * This class is used to fire rules used to extract and manipulate data from FHIR resources. + * This class is used to execute rules used to extract and manipulate data from FHIR resources. * * NOTE: that the [Facts] object is not thread safe, each thread should have its own set of data to * work on. When used in multi-threaded environment may exhibit unexpected behavior and return wrong - * results when rules are fired. Use the [ResourceDataRulesExecutor] in the same coroutine context - * of the caller. + * results when rules are fired. Use the [RulesExecutor] in the same coroutine context of the + * caller. */ -class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFactory) { +@Singleton +class RulesExecutor @Inject constructor(val rulesFactory: RulesFactory) { fun processResourceData( repositoryResourceData: RepositoryResourceData, - ruleConfigs: List, + rules: Rules, params: Map?, ): ResourceData { val computedValuesMap = computeResourceDataRules( - ruleConfigs = ruleConfigs, + rules = rules, repositoryResourceData = repositoryResourceData, params = params ?: emptyMap(), ) @@ -97,16 +101,15 @@ class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFacto * computation in a map; the name of the rule is used as the key. */ fun computeResourceDataRules( - ruleConfigs: List, + rules: Rules, repositoryResourceData: RepositoryResourceData?, params: Map, - ): Map { - return rulesFactory.fireRules( - rules = rulesFactory.generateRules(ruleConfigs), + ): Map = + rulesFactory.fireRules( + rules = rules, repositoryResourceData = repositoryResourceData, params = params, ) - } private fun List.mapToResourceData( listResourceConfig: ListResourceConfig, @@ -122,7 +125,7 @@ class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFacto addFirst(Pair(baseListResource, listResourceConfig.relatedResources)) } - val listItemRelatedResources = mutableMapOf>() + val listItemRelatedResources = ConcurrentHashMap>() while (relatedResourcesQueue.isNotEmpty()) { val (currentResource, currentListResourceConfig) = relatedResourcesQueue.removeFirst() currentListResourceConfig.forEach { relatedListResourceConfig -> @@ -171,12 +174,13 @@ class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFacto } } + val rules = rulesFactory.generateRules(ruleConfigs) val listComputedValuesMap = computeResourceDataRules( - ruleConfigs = ruleConfigs, + rules = rules, repositoryResourceData = RepositoryResourceData( - resourceRulesEngineFactId = listResourceConfig.id, + resourceConfigId = listResourceConfig.id, resource = baseListResource, relatedResourcesMap = listItemRelatedResources, ), 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 99af4f1a6a..d0ad82048e 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 @@ -30,9 +30,11 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import javax.inject.Inject +import javax.inject.Singleton import kotlin.system.measureTimeMillis import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import org.apache.commons.jexl3.JexlEngine import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Enumerations.DataType import org.hl7.fhir.r4.model.Resource @@ -63,6 +65,7 @@ import org.smartregister.fhircore.engine.util.extension.extractBirthDate import org.smartregister.fhircore.engine.util.extension.extractGender import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.formatDate +import org.smartregister.fhircore.engine.util.extension.generateRules import org.smartregister.fhircore.engine.util.extension.isOverDue import org.smartregister.fhircore.engine.util.extension.parseDate import org.smartregister.fhircore.engine.util.extension.prettifyDate @@ -71,6 +74,7 @@ import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import org.smartregister.fhircore.engine.util.helper.LocalizationHelper import timber.log.Timber +@Singleton class RulesFactory @Inject constructor( @@ -80,10 +84,14 @@ constructor( val dispatcherProvider: DispatcherProvider, val locationService: LocationService, val fhirContext: FhirContext, + val jexlEngine: JexlEngine, val defaultRepository: DefaultRepository, ) : RulesListener() { val rulesEngineService = RulesEngineService() - private var facts: Facts = Facts() + + @get:Synchronized @set:Synchronized private var facts: Facts = Facts() + + fun generateRules(ruleConfigs: List): Rules = ruleConfigs.generateRules(jexlEngine) /** * This function executes the actions defined in the [Rule] s generated from the provided list of @@ -92,11 +100,13 @@ constructor( * [RepositoryResourceData.relatedResourcesCountMap]. All related resources of same type are * flattened in a map for ease of usage in the rule engine. */ + @Synchronized fun fireRules( rules: Rules, repositoryResourceData: RepositoryResourceData?, params: Map, ): Map { + facts.clear() // Reset current facts facts = Facts().apply { put(FHIR_PATH, fhirPathDataExtractor) @@ -108,14 +118,14 @@ constructor( if (repositoryResourceData != null) { with(repositoryResourceData) { facts.apply { - put(resourceRulesEngineFactId ?: resource.resourceType.name, resource) + put(resourceConfigId ?: resource.resourceType.name, resource) relatedResourcesMap.addToFacts(this) relatedResourcesCountMap.addToFacts(this) // Populate the facts map with secondary resource data flatten base and related // resources secondaryRepositoryResourceData - ?.groupBy { it.resourceRulesEngineFactId ?: it.resource.resourceType.name } + ?.groupBy { it.resourceConfigId ?: it.resource.resourceType.name } ?.forEach { entry -> put(entry.key, entry.value.map { it.resource }) } secondaryRepositoryResourceData?.forEach { repoResourceData -> @@ -148,6 +158,26 @@ constructor( return facts.get(DATA) as Map } + fun fireRules(rules: Rules, baseResource: Resource? = null): Map { + facts.clear() // Reset current facts + facts = + Facts().apply { + put(FHIR_PATH, fhirPathDataExtractor) + put(DATA, mutableMapOf()) + put(DATE_SERVICE, DateService) + if (baseResource != null) { + put(baseResource.resourceType.name, baseResource) + } + } + if (BuildConfig.DEBUG) { + val timeToFireRules = measureTimeMillis { rulesEngine.fire(rules, facts) } + Timber.d("Rule executed in $timeToFireRules millisecond(s)") + } else { + rulesEngine.fire(rules, facts) + } + return facts.get(DATA) as Map + } + /** Provide access to utility functions accessible to the users defining rules in JSON format. */ inner class RulesEngineService { @@ -574,7 +604,7 @@ constructor( * [fhirPathExpression] to a list separated by the [separator] * * e.g for a provided list of Patients we can extract a string containing the family names using - * the [Patient.name.family] as the [fhirpathExpression] and [ | ] as the [separator] the + * the 'Patient.name.family' as the [fhirPathExpression] and [ | ] as the [separator] the * returned string would be [John | Jane | James] */ @JvmOverloads @@ -713,8 +743,8 @@ constructor( set(idPath, resource.id.replace("#", "")) } } - } catch (e: PathNotFoundException) { - Timber.e(e, "Path $path not found") + } catch (pathNotFoundException: PathNotFoundException) { + Timber.e(pathNotFoundException, "Path $path not found") jsonParse } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesListener.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesListener.kt index cc93ae8e2f..29ca5c1d2a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesListener.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesListener.kt @@ -16,36 +16,17 @@ package org.smartregister.fhircore.engine.rulesengine -import org.apache.commons.jexl3.JexlBuilder -import org.apache.commons.jexl3.JexlEngine import org.apache.commons.jexl3.JexlException import org.jeasy.rules.api.Facts import org.jeasy.rules.api.Rule import org.jeasy.rules.api.RuleListener -import org.jeasy.rules.api.Rules import org.jeasy.rules.core.DefaultRulesEngine -import org.jeasy.rules.jexl.JexlRule import org.smartregister.fhircore.engine.BuildConfig -import org.smartregister.fhircore.engine.domain.model.RuleConfig import timber.log.Timber abstract class RulesListener : RuleListener { protected val rulesEngine: DefaultRulesEngine = DefaultRulesEngine().also { it.registerRuleListener(this) } - private val jexlEngine: JexlEngine by lazy { - JexlBuilder() - .namespaces( - mutableMapOf( - "Timber" to Timber, - "StringUtils" to Class.forName("org.apache.commons.lang3.StringUtils"), - "RegExUtils" to Class.forName("org.apache.commons.lang3.RegExUtils"), - "Math" to Class.forName("java.lang.Math"), - ), - ) - .silent(false) - .strict(false) - .create() - } override fun beforeEvaluate(rule: Rule, facts: Facts): Boolean = true @@ -81,32 +62,8 @@ abstract class RulesListener : RuleListener { fun Map>.addToFacts(facts: Facts) = this.forEach { facts.put(it.key, it.value) } - fun generateRules(ruleConfigs: List): Rules = - Rules( - ruleConfigs - .map { ruleConfig -> - val customRule: JexlRule = - JexlRule(jexlEngine) - .name(ruleConfig.name) - .description(ruleConfig.description) - .priority(ruleConfig.priority) - .`when`(ruleConfig.condition.ifEmpty { TRUE }) - - for (action in ruleConfig.actions) { - try { - customRule.then(action) - } catch (jexlException: JexlException) { - Timber.e(jexlException) - continue // Skip action when an error occurs to avoid app force close - } - } - customRule - } - .toSet(), - ) - companion object { - private const val TRUE = "true" + const val TRUE = "true" const val DATA = "data" const val FHIR_PATH = "fhirPath" } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt index e16812a7ac..80d7988320 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt @@ -16,20 +16,36 @@ package org.smartregister.fhircore.engine.sync +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo +import android.graphics.drawable.Icon +import android.os.Build +import androidx.core.app.NotificationCompat import androidx.hilt.work.HiltWorker +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.ConflictResolver +import com.google.android.fhir.sync.CurrentSyncJobStatus import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.FhirSyncWorker +import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.SyncOperation import com.google.android.fhir.sync.upload.HttpCreateMethod import com.google.android.fhir.sync.upload.HttpUpdateMethod import com.google.android.fhir.sync.upload.UploadStrategy +import com.ibm.icu.util.Calendar import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.runBlocking +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.util.NotificationConstants +import org.smartregister.fhircore.engine.util.SharedPreferenceKey @HiltWorker class AppSyncWorker @@ -40,7 +56,14 @@ constructor( val syncListenerManager: SyncListenerManager, private val openSrpFhirEngine: FhirEngine, private val appTimeStampContext: AppTimeStampContext, -) : FhirSyncWorker(appContext, workerParams) { + private val configService: ConfigService, +) : FhirSyncWorker(appContext, workerParams), OnSyncListener { + private val notificationManager = + appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + init { + syncListenerManager.registerSyncListener(this) + } override fun getConflictResolver(): ConflictResolver = AcceptLocalConflictResolver @@ -50,6 +73,26 @@ constructor( context = appTimeStampContext, ) + override suspend fun doWork(): Result { + saveSyncStartTimestamp() + setForeground(getForegroundInfo()) + return super.doWork() + } + + private fun saveSyncStartTimestamp() { + syncListenerManager.sharedPreferencesHelper.write( + SharedPreferenceKey.SYNC_START_TIMESTAMP.name, + Calendar.getInstance().timeInMillis, + ) + } + + private fun saveSyncEndTimestamp() { + syncListenerManager.sharedPreferencesHelper.write( + SharedPreferenceKey.SYNC_END_TIMESTAMP.name, + Calendar.getInstance().timeInMillis, + ) + } + override fun getFhirEngine(): FhirEngine = openSrpFhirEngine override fun getUploadStrategy(): UploadStrategy = @@ -59,4 +102,77 @@ constructor( squash = true, bundleSize = 500, ) + + override suspend fun getForegroundInfo(): ForegroundInfo { + val channel = + NotificationChannel( + NotificationConstants.ChannelId.DATA_SYNC, + NotificationConstants.ChannelName.DATA_SYNC, + NotificationManager.IMPORTANCE_LOW, + ) + notificationManager.createNotificationChannel(channel) + + val notification: Notification = + buildNotification(progress = 0, isSyncUpload = false, isInitial = true) + + val foregroundInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NotificationConstants.NotificationId.DATA_SYNC, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + ForegroundInfo(NotificationConstants.NotificationId.DATA_SYNC, notification) + } + return foregroundInfo + } + + private fun getSyncProgress(completed: Int, total: Int) = + completed * 100 / if (total > 0) total else 1 + + override fun onSync(syncJobStatus: CurrentSyncJobStatus) { + when (syncJobStatus) { + is CurrentSyncJobStatus.Running -> { + if (syncJobStatus.inProgressSyncJob is SyncJobStatus.InProgress) { + val inProgressSyncJob = syncJobStatus.inProgressSyncJob as SyncJobStatus.InProgress + val isSyncUpload = inProgressSyncJob.syncOperation == SyncOperation.UPLOAD + val progressPercentage = + getSyncProgress(inProgressSyncJob.completed, inProgressSyncJob.total) + updateNotificationProgress(progress = progressPercentage, isSyncUpload = isSyncUpload) + } + } + is CurrentSyncJobStatus.Succeeded -> saveSyncEndTimestamp() + else -> {} + } + } + + private fun buildNotification( + progress: Int, + isSyncUpload: Boolean, + isInitial: Boolean, + ): Notification { + return NotificationCompat.Builder(applicationContext, NotificationConstants.ChannelId.DATA_SYNC) + .setContentTitle( + applicationContext.getString( + if (isInitial) { + R.string.syncing_initiated + } else if (isSyncUpload) R.string.syncing_up else R.string.syncing_down, + ), + ) + .setSmallIcon(R.drawable.ic_opensrp_small_logo) + .setLargeIcon(Icon.createWithResource(applicationContext, configService.getLauncherIcon())) + .setContentText(applicationContext.getString(R.string.percentage_progress, progress)) + .setProgress(100, progress, progress == 0) + .setOngoing(true) + .build() + } + + private fun updateNotificationProgress(progress: Int, isSyncUpload: Boolean) { + val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = + buildNotification(progress = progress, isSyncUpload = isSyncUpload, isInitial = false) + notificationManager.notify(NotificationConstants.NotificationId.DATA_SYNC, notification) + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt index 00c66ddce4..d084c33d4d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt @@ -49,7 +49,7 @@ constructor( val (resourceSearchParams, _) = loadResourceSearchParams() Timber.i("Custom resource sync parameters $resourceSearchParams") resourceSearchParams - .asSequence() + .asIterable() .filter { it.value.isNotEmpty() } .map { "${it.key}?${it.value.concatParams()}" } .forEach { url -> diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt index b94a78381b..6b4947e53d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt @@ -20,6 +20,7 @@ import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.download.DownloadRequest import com.google.android.fhir.sync.download.ResourceParamsBasedDownloadWorkManager import com.google.android.fhir.sync.download.ResourceSearchParams +import com.google.android.fhir.sync.download.UrlDownloadRequest import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.util.extension.updateLastUpdated @@ -32,7 +33,12 @@ class OpenSrpDownloadManager( private val downloadWorkManager = ResourceParamsBasedDownloadWorkManager(resourceSearchParams, context) - override suspend fun getNextRequest(): DownloadRequest? = downloadWorkManager.getNextRequest() + override suspend fun getNextRequest(): DownloadRequest? = + downloadWorkManager.getNextRequest().apply { + if (this is UrlDownloadRequest) { + url.replace("_pretty=true", "_pretty=false") + } + } override suspend fun getSummaryRequestUrls(): Map = downloadWorkManager.getSummaryRequestUrls() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt index 7eb2ab584f..59c9413d93 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt @@ -17,16 +17,19 @@ package org.smartregister.fhircore.engine.sync import android.content.Context +import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.google.android.fhir.FhirEngine +import com.google.android.fhir.sync.BackoffCriteria import com.google.android.fhir.sync.CurrentSyncJobStatus import com.google.android.fhir.sync.LastSyncJobStatus import com.google.android.fhir.sync.PeriodicSyncConfiguration import com.google.android.fhir.sync.PeriodicSyncJobStatus import com.google.android.fhir.sync.RepeatInterval +import com.google.android.fhir.sync.RetryConfiguration import com.google.android.fhir.sync.Sync import com.google.android.fhir.sync.SyncJobStatus import com.google.android.fhir.sync.download.ResourceParamsBasedDownloadWorkManager @@ -77,6 +80,11 @@ constructor( .setConstraints( Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), ) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + 10, + TimeUnit.SECONDS, + ) .build(), ) } @@ -95,6 +103,16 @@ constructor( syncConstraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), repeat = RepeatInterval(interval = interval, timeUnit = TimeUnit.MINUTES), + retryConfiguration = + RetryConfiguration( + backoffCriteria = + BackoffCriteria( + backoffDelay = 10, + timeUnit = TimeUnit.SECONDS, + backoffPolicy = BackoffPolicy.EXPONENTIAL, + ), + maxRetries = 3, + ), ), ) .handlePeriodicSyncJobStatus(this) @@ -106,9 +124,11 @@ constructor( this.onEach { syncListenerManager.onSyncListeners.forEach { onSyncListener -> onSyncListener.onSync( - if (it.lastSyncJobStatus != null) { + if (it.lastSyncJobStatus as? LastSyncJobStatus.Succeeded != null) { CurrentSyncJobStatus.Succeeded((it.lastSyncJobStatus as LastSyncJobStatus).timestamp) - } else it.currentSyncJobStatus, + } else { + it.currentSyncJobStatus + }, ) } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt index bc865b01c0..9015ef7a14 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt @@ -73,6 +73,15 @@ constructor( } } + fun registerSyncListener(onSyncListener: OnSyncListener) { + if (_onSyncListeners.find { it.get() == onSyncListener } == null) { + _onSyncListeners.add(WeakReference(onSyncListener)) + Timber.w("${onSyncListener::class.simpleName} registered to receive sync state events") + } + + _onSyncListeners.removeIf { it.get() == null } + } + /** * This function removes [onSyncListener] from the list of registered [OnSyncListener]'s to stop * receiving sync state events. diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt index fafba12498..95d9c5fc53 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt @@ -250,12 +250,12 @@ constructor( } suspend fun closeFhirResources() { - val appRegistry = + val applicationConfiguration = configurationRegistry.retrieveConfiguration( ConfigType.Application, ) - appRegistry.eventWorkflows + applicationConfiguration.eventWorkflows .filter { it.eventType == EventType.RESOURCE_CLOSURE } .forEach { eventWorkFlow -> eventWorkFlow.eventResources.forEach { eventResource -> diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt index bee9d7febd..bf61fd4717 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt @@ -43,6 +43,12 @@ enum class AlertIntent { data class AlertDialogListItem(val key: String, val value: String) +data class AlertDialogButton( + val listener: ((d: DialogInterface) -> Unit)? = null, + @StringRes val text: Int? = null, + val color: Int? = null, +) + object AlertDialogue { private val ITEMS_LIST_KEY = "alert_dialog_items_list" @@ -51,12 +57,9 @@ object AlertDialogue { alertIntent: AlertIntent, message: CharSequence, title: String? = null, - confirmButtonListener: ((d: DialogInterface) -> Unit)? = null, - @StringRes confirmButtonText: Int = R.string.questionnaire_alert_confirm_button_title, - neutralButtonListener: ((d: DialogInterface) -> Unit)? = null, - @StringRes neutralButtonText: Int = R.string.questionnaire_alert_neutral_button_title, - negativeButtonListener: ((d: DialogInterface) -> Unit)? = null, - @StringRes negativeButtonText: Int = R.string.questionnaire_alert_negative_button_title, + confirmButton: AlertDialogButton? = null, + neutralButton: AlertDialogButton? = null, + negativeButton: AlertDialogButton? = null, cancellable: Boolean = false, options: Array? = null, ): AlertDialog { @@ -67,22 +70,48 @@ object AlertDialogue { setView(view) title?.let { setTitle(it) } setCancelable(cancellable) - neutralButtonListener?.let { - setNeutralButton(neutralButtonText) { d, _ -> neutralButtonListener.invoke(d) } + neutralButton?.listener?.let { + setNeutralButton( + neutralButton.text ?: R.string.questionnaire_alert_neutral_button_title, + ) { d, _ -> + neutralButton.listener.invoke(d) + } } - confirmButtonListener?.let { - setPositiveButton(confirmButtonText) { d, _ -> confirmButtonListener.invoke(d) } + confirmButton?.listener?.let { + setPositiveButton( + confirmButton.text ?: R.string.questionnaire_alert_confirm_button_title, + ) { d, _ -> + confirmButton.listener.invoke(d) + } } - negativeButtonListener?.let { - setNegativeButton(negativeButtonText) { d, _ -> negativeButtonListener.invoke(d) } + negativeButton?.listener?.let { + setNegativeButton( + negativeButton.text ?: R.string.questionnaire_alert_negative_button_title, + ) { d, _ -> + negativeButton.listener.invoke(d) + } } options?.run { setSingleChoiceItems(options.map { it.value }.toTypedArray(), -1, null) } } .show() + val neutralButtonColor = neutralButton?.color ?: R.color.grey_text_color dialog .getButton(AlertDialog.BUTTON_NEUTRAL) - .setTextColor(ContextCompat.getColor(context, R.color.grey_text_color)) + .setTextColor(ContextCompat.getColor(context, neutralButtonColor)) + + if (confirmButton?.color != null) { + dialog + .getButton(AlertDialog.BUTTON_POSITIVE) + .setTextColor(ContextCompat.getColor(context, confirmButton.color)) + } + + if (negativeButton?.color != null) { + dialog + .getButton(AlertDialog.BUTTON_NEGATIVE) + .setTextColor(ContextCompat.getColor(context, negativeButton.color)) + } + dialog.findViewById(R.id.pr_circular)?.apply { if (alertIntent == AlertIntent.PROGRESS) { this.show() @@ -115,8 +144,11 @@ object AlertDialogue { alertIntent = AlertIntent.INFO, message = message, title = title, - confirmButtonListener = confirmButtonListener, - confirmButtonText = confirmButtonText, + confirmButton = + AlertDialogButton( + listener = confirmButtonListener, + text = confirmButtonText, + ), ) } @@ -126,8 +158,11 @@ object AlertDialogue { alertIntent = AlertIntent.ERROR, message = message, title = title, - confirmButtonListener = { d -> d.dismiss() }, - confirmButtonText = R.string.questionnaire_alert_ack_button_title, + confirmButton = + AlertDialogButton( + listener = { d -> d.dismiss() }, + text = R.string.questionnaire_alert_ack_button_title, + ), ) } @@ -160,25 +195,28 @@ object AlertDialogue { alertIntent = AlertIntent.CONFIRM, message = context.getString(message), title = title?.let { context.getString(it) }, - confirmButtonListener = confirmButtonListener, - confirmButtonText = confirmButtonText, - neutralButtonListener = { d -> d.dismiss() }, - neutralButtonText = R.string.questionnaire_alert_neutral_button_title, + confirmButton = + AlertDialogButton( + listener = confirmButtonListener, + text = confirmButtonText, + ), + neutralButton = + AlertDialogButton( + listener = { d -> d.dismiss() }, + text = R.string.questionnaire_alert_neutral_button_title, + ), cancellable = false, options = options?.toTypedArray(), ) } - fun showCancelAlert( + fun showThreeButtonAlert( context: Context, @StringRes message: Int, @StringRes title: Int? = null, - confirmButtonListener: ((d: DialogInterface) -> Unit), - @StringRes confirmButtonText: Int, - neutralButtonListener: ((d: DialogInterface) -> Unit), - @StringRes neutralButtonText: Int, - negativeButtonListener: ((d: DialogInterface) -> Unit), - @StringRes negativeButtonText: Int, + confirmButton: AlertDialogButton? = null, + neutralButton: AlertDialogButton? = null, + negativeButton: AlertDialogButton? = null, cancellable: Boolean = true, options: List? = null, ): AlertDialog { @@ -187,12 +225,9 @@ object AlertDialogue { alertIntent = AlertIntent.CONFIRM, message = context.getString(message), title = title?.let { context.getString(it) }, - confirmButtonListener = confirmButtonListener, - confirmButtonText = confirmButtonText, - neutralButtonListener = neutralButtonListener, - neutralButtonText = neutralButtonText, - negativeButtonListener = negativeButtonListener, - negativeButtonText = negativeButtonText, + confirmButton = confirmButton, + neutralButton = neutralButton, + negativeButton = negativeButton, cancellable = cancellable, options = options?.toTypedArray(), ) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/LineSpinFadeLoaderProgressIndicator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/LineSpinFadeLoaderProgressIndicator.kt new file mode 100644 index 0000000000..78d287df1d --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/LineSpinFadeLoaderProgressIndicator.kt @@ -0,0 +1,149 @@ +/* + * 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.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated + +const val ANIMATION_LABEL = "LineSpinFadeLoaderProgressIndicator" + +/** + * A custom progress indicator that displays rotating lines in a circular pattern. Each line fades + * in and out as it rotates, creating a smooth loading animation effect. + * + * @param modifier Modifier to be applied to the Canvas composable + * @param color The color of the lines in the loading indicator + * @param lineCount The number of lines to be displayed in the circular pattern (default is 8) + * @param lineWidth The width/thickness of each line (default is 3f) + * @param lineLength The length of each line (default is 8f) + * @param innerRadius The radius of the circle on which the lines are positioned (default is 10f) + * + * Example usage: + * ``` + * LineSpinFadeLoaderProgressIndicator( + * modifier = Modifier.size(80.dp), + * color = Color.Blue, + * lineCount = 8, + * lineWidth = 3f, + * lineLength = 8f, + * innerRadius = 10f + * ) + * ``` + * + * The animation creates a rotating effect where: + * - All lines are visible simultaneously + * - Each line's opacity changes based on its current position in the rotation + * - Lines maintain fixed positions but fade in/out to create a rotation illusion + * - The animation continuously loops with a smooth transition + * + * @see Canvas + * @see rememberInfiniteTransition + */ +@Composable +fun LineSpinFadeLoaderProgressIndicator( + modifier: Modifier = Modifier, + color: Color = Color.Blue, + lineCount: Int = 12, + lineWidth: Float = 4f, + lineLength: Float = 20f, + innerRadius: Float = 20f, +) { + val infiniteTransition = rememberInfiniteTransition(ANIMATION_LABEL) + + val rotationAnimation by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = lineCount.toFloat(), + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = ANIMATION_LABEL, + ) + + Canvas(modifier = modifier.wrapContentSize()) { + val canvasWidth = size.width + val canvasHeight = size.height + val centerX = canvasWidth / 2 + val centerY = canvasHeight / 2 + + for (i in 0 until lineCount) { + val angle = 2 * PI * i / lineCount + val startX = centerX + cos(angle).toFloat() * innerRadius + val startY = centerY + sin(angle).toFloat() * innerRadius + val endX = centerX + cos(angle).toFloat() * (innerRadius + lineLength) + val endY = centerY + sin(angle).toFloat() * (innerRadius + lineLength) + + // Calculate alpha based on the current rotation + val distance = (i - rotationAnimation + lineCount) % lineCount + val alpha = + when { + distance < lineCount / 2f -> 1f - (distance / (lineCount / 2f)) + else -> (distance - (lineCount / 2f)) / (lineCount / 2f) + } + + drawLine( + color = color.copy(alpha = alpha), + start = androidx.compose.ui.geometry.Offset(startX, startY), + end = androidx.compose.ui.geometry.Offset(endX, endY), + strokeWidth = lineWidth, + cap = StrokeCap.Round, + ) + } + } +} + +@PreviewWithBackgroundExcludeGenerated +@Composable +fun LoadingScreen() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LineSpinFadeLoaderProgressIndicator( + modifier = Modifier.padding(8.dp), + color = Color.Blue, + ) + + Spacer(modifier = Modifier.height(32.dp)) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/LoaderDialog.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/LoaderDialog.kt index da98fa0606..2ceeda3d5a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/LoaderDialog.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/LoaderDialog.kt @@ -19,10 +19,9 @@ package org.smartregister.fhircore.engine.ui.components.register import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Surface @@ -37,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -44,6 +44,7 @@ import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.ui.components.LineSpinFadeLoaderProgressIndicator import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated const val LOADER_DIALOG_PROGRESS_BAR_TAG = "loaderDialogProgressBarTag" @@ -52,58 +53,113 @@ const val LOADER_DIALOG_PROGRESS_MSG_TAG = "loaderDialogProgressMsgTag" @Composable fun LoaderDialog( modifier: Modifier = Modifier, - dialogMessage: String, + dialogMessage: String? = null, percentageProgressFlow: Flow = flowOf(0), showPercentageProgress: Boolean = false, + boxWidth: Dp = 240.dp, + boxHeight: Dp = 180.dp, + progressBarSize: Dp = 40.dp, + showBackground: Boolean = true, + showLineSpinIndicator: Boolean = false, + showOverlay: Boolean = true, + alignment: Alignment = Alignment.Center, ) { val currentPercentage = percentageProgressFlow.collectAsState(0).value + + if (showOverlay) { + Dialog(onDismissRequest = {}, properties = DialogProperties(dismissOnBackPress = false)) { + LoaderContent( + modifier = modifier, + dialogMessage = dialogMessage, + currentPercentage = currentPercentage, + showPercentageProgress = showPercentageProgress, + boxWidth = boxWidth, + boxHeight = boxHeight, + progressBarSize = progressBarSize, + showBackground = showBackground, + showLineSpinIndicator = showLineSpinIndicator, + ) + } + } else { + Box( + modifier = modifier.wrapContentSize(), + contentAlignment = alignment, + ) { + LoaderContent( + modifier = modifier, + dialogMessage = dialogMessage, + currentPercentage = currentPercentage, + showPercentageProgress = showPercentageProgress, + boxWidth = boxWidth, + boxHeight = boxHeight, + progressBarSize = progressBarSize, + showBackground = showBackground, + showLineSpinIndicator = showLineSpinIndicator, + ) + } + } +} + +@Composable +private fun LoaderContent( + modifier: Modifier, + dialogMessage: String?, + currentPercentage: Int, + showPercentageProgress: Boolean, + boxWidth: Dp, + boxHeight: Dp, + progressBarSize: Dp, + showBackground: Boolean, + showLineSpinIndicator: Boolean, +) { val openDialog = remember { mutableStateOf(true) } if (openDialog.value) { - Dialog( - onDismissRequest = { openDialog.value = true }, - properties = DialogProperties(dismissOnBackPress = true), - ) { - Box(modifier.size(240.dp, 180.dp)) { - Column( - modifier = modifier.padding(8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + Box(modifier.size(boxWidth, boxHeight)) { + Column( + modifier = modifier.padding(8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + modifier = modifier.size(boxWidth, boxHeight), + shape = RoundedCornerShape(8.dp), + color = if (showBackground) Color.Black.copy(alpha = 0.56f) else Color.Transparent, ) { - Surface( - color = Color.Black.copy(alpha = 0.56f), - modifier = modifier.fillMaxSize(), - shape = RoundedCornerShape(8), + Column( + modifier = modifier.padding(8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { + if (showLineSpinIndicator) { + LineSpinFadeLoaderProgressIndicator( + color = Color.White, + lineLength = 12f, + innerRadius = 16f, + ) + } else { CircularProgressIndicator( color = Color.White, strokeWidth = 3.dp, - modifier = modifier.testTag(LOADER_DIALOG_PROGRESS_BAR_TAG).size(40.dp), + modifier = modifier.testTag(LOADER_DIALOG_PROGRESS_BAR_TAG).size(progressBarSize), ) - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - fontSize = 16.sp, - color = Color.White, - text = dialogMessage, - modifier = - modifier.testTag(LOADER_DIALOG_PROGRESS_MSG_TAG).padding(vertical = 16.dp), - ) + } - if (showPercentageProgress) { - Text( - fontSize = 15.sp, - color = Color.White, - text = stringResource(id = R.string.percentage_progress, currentPercentage), - modifier = modifier.padding(horizontal = 3.dp, vertical = 16.dp), - ) - } - } + dialogMessage?.let { + Text( + text = it, + color = Color.White, + fontSize = 14.sp, + modifier = modifier.testTag(LOADER_DIALOG_PROGRESS_MSG_TAG).padding(top = 8.dp), + ) + } + + if (showPercentageProgress) { + Text( + fontSize = 15.sp, + color = Color.White, + text = "$currentPercentage%", + modifier = modifier.padding(top = 4.dp), + ) } } } @@ -122,3 +178,16 @@ fun LoaderDialog( fun LoaderDialogPreview() { LoaderDialog(dialogMessage = stringResource(id = R.string.syncing)) } + +@PreviewWithBackgroundExcludeGenerated +@Composable +fun LoaderDialogPreviewTest() { + LoaderDialog( + boxWidth = 50.dp, + boxHeight = 50.dp, + progressBarSize = 25.dp, + showBackground = false, + showLineSpinIndicator = true, + showOverlay = false, + ) +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/NotificationConstants.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/NotificationConstants.kt new file mode 100644 index 0000000000..d1e42d3824 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/NotificationConstants.kt @@ -0,0 +1,33 @@ +/* + * 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.util + +/** This class has method to help track, manage Notifications identifiers */ +object NotificationConstants { + + object NotificationId { + const val DATA_SYNC = 1 + } + + object ChannelId { + const val DATA_SYNC = "channel_id_datasync" + } + + object ChannelName { + const val DATA_SYNC = "Data sync" + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/ParallelUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/ParallelUtil.kt index 78b46a65bb..331212708a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/ParallelUtil.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/ParallelUtil.kt @@ -16,9 +16,11 @@ package org.smartregister.fhircore.engine.util +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch /** * Launch a new coroutine for each map iteration using async. From @@ -26,18 +28,24 @@ import kotlinx.coroutines.coroutineScope * * @param A the type of elements in the iterable * @param B the type of elements returned by the function + * @param dispatcher dispatcher that creates the async coroutine * @param f the function to apply to the elements * @return the resulting list after apply *f* to the elements of the iterable */ -suspend fun Iterable.pmap(f: suspend (A) -> B): Iterable = coroutineScope { - map { async { f(it) } }.awaitAll() -} +suspend fun Iterable.pmap(dispatcher: CoroutineDispatcher, f: suspend (A) -> B): List = + coroutineScope { + map { async(dispatcher) { f(it) } }.awaitAll() + } /** - * Launch a new coroutine for each loop iteration using async. + * Launch a new coroutine for each loop iteration using launch and the specified Dispatcher for + * computationaly intensive tasks. * * @param T the type of elements in the iterable + * @param dispatcher dispatcher that creates the async coroutine + * @param action the function to apply to the elements */ -suspend fun Iterable.forEachAsync(action: suspend (T) -> Unit): Unit = coroutineScope { - forEach { async { action(it) } } -} +suspend fun Iterable.forEachAsync( + dispatcher: CoroutineDispatcher, + action: suspend (T) -> Unit, +): Unit = coroutineScope { forEach { launch(dispatcher) { action(it) } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt index 9b5b5e0432..99fa88f93b 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt @@ -34,4 +34,6 @@ enum class SharedPreferenceKey { ORGANIZATION, GEO_LOCATION, SELECTED_LOCATION_ID, + SYNC_START_TIMESTAMP, + SYNC_END_TIMESTAMP, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ApplicationExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ApplicationExtension.kt index 7ba13b6256..b5aba57984 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ApplicationExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ApplicationExtension.kt @@ -18,10 +18,17 @@ package org.smartregister.fhircore.engine.util.extension import java.net.URL import java.util.Locale +import org.apache.commons.jexl3.JexlEngine +import org.apache.commons.jexl3.JexlException +import org.jeasy.rules.api.Rules +import org.jeasy.rules.jexl.JexlRule import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.domain.model.Language +import org.smartregister.fhircore.engine.domain.model.RuleConfig +import org.smartregister.fhircore.engine.rulesengine.RulesListener +import timber.log.Timber fun ConfigurationRegistry.fetchLanguages() = this.retrieveConfiguration(ConfigType.Application) @@ -29,3 +36,28 @@ fun ConfigurationRegistry.fetchLanguages() = .map { Language(it, Locale.forLanguageTag(it).displayName) } fun URL.getSubDomain() = this.host.substringBeforeLast('.').substringBeforeLast('.') + +@Synchronized +fun List.generateRules(jexlEngine: JexlEngine): Rules = + Rules( + this.asSequence() + .map { ruleConfig -> + val customRule: JexlRule = + JexlRule(jexlEngine) + .name(ruleConfig.name) + .description(ruleConfig.description) + .priority(ruleConfig.priority) + .`when`(ruleConfig.condition.ifEmpty { RulesListener.TRUE }) + + for (action in ruleConfig.actions) { + try { + customRule.then(action) + } catch (jexlException: JexlException) { + Timber.e(jexlException) + continue // Skip action when an error occurs to avoid app force close + } + } + customRule + } + .toSet(), + ) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt index ff4b014e10..9c7280b57f 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt @@ -32,6 +32,8 @@ import org.hl7.fhir.r4.model.RelatedArtifact import org.hl7.fhir.r4.model.Resource import timber.log.Timber +private const val PAGE_SIZE = 100 + suspend inline fun FhirEngine.loadResource(resourceId: String): T? { return try { this.get(resourceId) @@ -97,24 +99,27 @@ suspend fun FhirEngine.countUnSyncedResources() = .eachCount() .map { it.key to it.value } -suspend fun FhirEngine.batchedSearch(search: Search) = +suspend fun FhirEngine.batchedSearch(search: Search): List> { + val pageSize = PAGE_SIZE if (search.count != null) { - this.search(search) - } else { - val result = mutableListOf>() - var offset = search.from ?: 0 - val pageCount = 100 - do { - search.from = offset - search.count = pageCount - val searchResults = this.search(search) - result += searchResults - offset += searchResults.size - } while (searchResults.size == pageCount) - - result + return this.search(search) } + val result = mutableListOf>() + var offset = search.from ?: 0 + do { + val paginatedSearch = + search.apply { + search.from = offset + search.count = pageSize + } + val searchResults = this.search(paginatedSearch) + result.addAll(searchResults) + offset += searchResults.size + } while (searchResults.size == pageSize) + return result +} + suspend inline fun FhirEngine.batchedSearch( init: Search.() -> Unit, ): List> { 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 5d2db45505..4b21562dcc 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 @@ -18,7 +18,6 @@ package org.smartregister.fhircore.engine.util.extension 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.logicalId import com.google.android.fhir.get @@ -114,23 +113,20 @@ fun Base?.valueToString(datePattern: String = "dd-MMM-yyyy"): String { fun CodeableConcept.stringValue(): String = this.text ?: this.codingFirstRep.display ?: this.codingFirstRep.code -fun Resource.encodeResourceToString( - parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), -): String = parser.encodeResourceToString(this.copy()) +fun Resource.encodeResourceToString(): String = + FhirContext.forR4().getCustomJsonParser().encodeResourceToString(this.copy()) -fun StructureMap.encodeResourceToString( - parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), -): String = - parser +fun StructureMap.encodeResourceToString(): String = + FhirContext.forR4() + .getCustomJsonParser() .encodeResourceToString(this) .replace("'months'", "\\\\'months\\\\'") .replace("'days'", "\\\\'days\\\\'") .replace("'years'", "\\\\'years\\\\'") .replace("'weeks'", "\\\\'weeks\\\\'") -fun String.decodeResourceFromString( - parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), -): T = parser.parseResource(this) as T +fun String.decodeResourceFromString(): T = + FhirContext.forR4().getCustomJsonParser().parseResource(this) as T fun T.updateFrom(updatedResource: Resource): T { var extensionUpdateFrom = listOf() @@ -141,38 +137,40 @@ fun T.updateFrom(updatedResource: Resource): T { if (this is Patient) { extension = this.extension } - val jsonParser = FhirContext.forR4Cached().getCustomJsonParser() - val stringJson = encodeResourceToString(jsonParser) + val stringJson = encodeResourceToString() val originalResourceJson = JSONObject(stringJson) - originalResourceJson.updateFrom(JSONObject(updatedResource.encodeResourceToString(jsonParser))) - return jsonParser.parseResource(this::class.java, originalResourceJson.toString()).apply { - val meta = this.meta - val metaUpdateFrom = this@updateFrom.meta - if ((meta == null || meta.isEmpty)) { - if (metaUpdateFrom != null) { - this.meta = metaUpdateFrom - this.meta.tag = metaUpdateFrom.tag - } - } else { - val setOfTags = mutableSetOf() - setOfTags.addAll(meta.tag) - setOfTags.addAll(metaUpdateFrom.tag) - this.meta.tag = setOfTags.distinctBy { it.code + it.system } - } - if (this is Patient && this@updateFrom is Patient && updatedResource is Patient) { - if (extension.isEmpty()) { - if (extensionUpdateFrom.isNotEmpty()) { - this.extension = extensionUpdateFrom + originalResourceJson.updateFrom(JSONObject(updatedResource.encodeResourceToString())) + return FhirContext.forR4() + .getCustomJsonParser() + .parseResource(this::class.java, originalResourceJson.toString()) + .apply { + val meta = this.meta + val metaUpdateFrom = this@updateFrom.meta + if ((meta == null || meta.isEmpty)) { + if (metaUpdateFrom != null) { + this.meta = metaUpdateFrom + this.meta.tag = metaUpdateFrom.tag } } else { - val setOfExtension = mutableSetOf() - setOfExtension.addAll(extension) - setOfExtension.addAll(extensionUpdateFrom) - this.extension = setOfExtension.distinct() + val setOfTags = mutableSetOf() + setOfTags.addAll(meta.tag) + setOfTags.addAll(metaUpdateFrom.tag) + this.meta.tag = setOfTags.distinctBy { it.code + it.system } + } + if (this is Patient && this@updateFrom is Patient && updatedResource is Patient) { + if (extension.isEmpty()) { + if (extensionUpdateFrom.isNotEmpty()) { + this.extension = extensionUpdateFrom + } + } else { + val setOfExtension = mutableSetOf() + setOfExtension.addAll(extension) + setOfExtension.addAll(extensionUpdateFrom) + this.extension = setOfExtension.distinct() + } } } - } } @Throws(JSONException::class) @@ -439,7 +437,7 @@ fun Composition.retrieveCompositionSections(): List = - FhirContext.forR4Cached().getResourceDefinition(this).implementingClass as Class + FhirContext.forR4().getResourceDefinition(this).implementingClass as Class /** * A function that extracts only the UUID part of a resource logicalId. @@ -547,11 +545,11 @@ suspend fun Task.updateDependentTaskDueDate( * to be a boolean otherwise the [toBoolean] function will evaluate to false and hence return an * empty list. */ -fun List.filterByFhirPathExpression( +fun Collection.filterByFhirPathExpression( fhirPathDataExtractor: FhirPathDataExtractor, conditionalFhirPathExpressions: List?, matchAll: Boolean, -): List { +): Collection { if (conditionalFhirPathExpressions.isNullOrEmpty()) return this return this.filter { repositoryResourceData -> if (matchAll) { diff --git a/android/engine/src/main/res/drawable/ic_opensrp_small_logo.png b/android/engine/src/main/res/drawable/ic_opensrp_small_logo.png new file mode 100644 index 0000000000..0b490b2c67 Binary files /dev/null and b/android/engine/src/main/res/drawable/ic_opensrp_small_logo.png differ diff --git a/android/engine/src/main/res/values-es/strings.xml b/android/engine/src/main/res/values-es/strings.xml index 5ba9f19d9c..1caafb6c34 100644 --- a/android/engine/src/main/res/values-es/strings.xml +++ b/android/engine/src/main/res/values-es/strings.xml @@ -23,8 +23,8 @@ Cerrar sesión como %1$s Sincronización completa Sincronización - Sincronizando - Sincronizando + Sincronizando… + Sincronizando… Sincronización iniciada… Error de sincronización. Verifique la conexión a Internet o vuelva a intentarlo más tarde Sincronización completada con errores. Reintentando… diff --git a/android/engine/src/main/res/values-fr/strings.xml b/android/engine/src/main/res/values-fr/strings.xml index c3ca7b188d..c6da757598 100644 --- a/android/engine/src/main/res/values-fr/strings.xml +++ b/android/engine/src/main/res/values-fr/strings.xml @@ -23,8 +23,8 @@ Se déconnecter en tant que %1$s Synchronisation terminée Synchronisation - Synchronisation - Synchronisation en cours + Synchronisation… + Synchronisation en cours… Synchronisation initiée… La synchronisation a échoué. Vérifier la connexion internet ou réessayer plus tard La synchronisation s\'est terminée avec des erreurs. Réessayer... diff --git a/android/engine/src/main/res/values-in/strings.xml b/android/engine/src/main/res/values-in/strings.xml index 4200c79864..9e674f4fc5 100644 --- a/android/engine/src/main/res/values-in/strings.xml +++ b/android/engine/src/main/res/values-in/strings.xml @@ -20,8 +20,8 @@ Keluar sebagai %1$s Sinkronisasi selesai Sinkronisasi - Menyinkronkan - Menyinkronkan + Menyinkronkan… + Menyinkronkan… Sinkronisasi dimulai… Sinkronisasi gagal. Periksa koneksi internet atau coba lagi nanti Sinkronisasi selesai dengan kesalahan. Mencoba lagi… diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index 9003f2e851..9086e4a8a8 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Manual Sync + Manual sync Sync Language Log out as @@ -24,8 +24,8 @@ Log out as %1$s Sync complete Syncing - Syncing up - Syncing down + Syncing up… + Syncing down… Sync initiated… Sync failed. Check internet connection or try again later Sync completed with errors. Retrying… @@ -76,6 +76,8 @@ Given details have validation errors. Resolve errors and submit again Validation Failed OK + Open draft + Delete draft Username Password Forgot Password @@ -202,4 +204,6 @@ APPLY FILTER Save draft changes Do you want to save draft changes? + Open draft changes + You can reopen a saved draft form to continue or delete it diff --git a/android/engine/src/main/res/values/styles.xml b/android/engine/src/main/res/values/styles.xml index eb95cda512..755e04226e 100644 --- a/android/engine/src/main/res/values/styles.xml +++ b/android/engine/src/main/res/values/styles.xml @@ -84,6 +84,7 @@ @color/colorPrimary +