From bcff5264c1297d8a02d61b78d55ee25dea1c6e85 Mon Sep 17 00:00:00 2001 From: Rkareko <47570855+Rkareko@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:22:19 +0300 Subject: [PATCH 01/12] Delete saved draft feature (#3631) * Add delete draft workflow * Add view model * Add logic for soft deleteing drafts * Run the search for QuestinnaireResponse in view model scope * Move searchQuestionnaireResponse function to default repository * Use interpolated questionnaire config when launching delete draft fragment * Ensure delete draft db calls complete before dialog is dismissed * Add flag to indicate a drft has been deleted * Use aduti event to keep track of deleted drafts * Rename delete draft questionnaire workflow move delete draft classes to questionnaire package * Rename draft dialog fragment and view model * Add data class to hold alert dialog button properties Add ability to set alert dialog button color * Fix failing tests * Update CHANGELOG.md * Verify filtering by encounter works when when searching for latest QR * Run spotless Apply * Update test name Co-authored-by: Martin Ndegwa * Add documentation for delete draft functionality * Add qustionnaire draft dialog view tests * Run questionnaire soft deletion and audit event creation in a transaction block * Update docs --------- Co-authored-by: Martin Ndegwa --- CHANGELOG.md | 3 +- .../workflow/ApplicationWorkflow.kt | 3 + .../engine/data/local/DefaultRepository.kt | 44 ++++ .../fhircore/engine/ui/base/AlertDialogue.kt | 103 ++++++--- .../engine/src/main/res/values/strings.xml | 4 + .../data/local/DefaultRepositoryTest.kt | 133 ++++++++++++ .../engine/ui/base/AlertDialogueTest.kt | 37 +++- .../quest/navigation/MainNavigationScreen.kt | 5 + .../quest/navigation/NavigationArg.kt | 1 + .../ui/geowidget/GeoWidgetLauncherFragment.kt | 23 +- .../fhircore/quest/ui/main/AppMainActivity.kt | 11 +- .../ui/questionnaire/QuestionnaireActivity.kt | 48 +++-- .../QuestionnaireDraftDialogFragment.kt | 81 +++++++ .../QuestionnaireDraftDialogViewModel.kt | 126 +++++++++++ .../questionnaire/QuestionnaireViewModel.kt | 49 +---- .../quest/util/extensions/ConfigExtensions.kt | 9 + .../res/navigation/application_nav_graph.xml | 8 + .../QuestionnaireDraftDialogViewModelTest.kt | 197 ++++++++++++++++++ .../QuestionnaireViewModelTest.kt | 98 +-------- .../configuring/forms/save-form-as-draft.mdx | 120 ++++++++++- 20 files changed, 877 insertions(+), 226 deletions(-) create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogFragment.kt create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModel.kt create mode 100644 android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6de70910..c6b01eff17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ 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 ## [1.1.0] - 2024-02-15 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..d457ef68d9 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 @@ -62,6 +62,8 @@ 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 @@ -1292,6 +1294,48 @@ constructor( ) .mapTo(ArrayDeque()) { it.resource } + /** + * 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. + */ + 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 } + } + /** * 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. 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/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index 9003f2e851..e22a4b54f1 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -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/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt index 4dcbb39ec1..04495a7962 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt @@ -65,6 +65,9 @@ import org.hl7.fhir.r4.model.Organization import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Period import org.hl7.fhir.r4.model.Procedure +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseStatus import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource @@ -80,6 +83,7 @@ import org.junit.Test import org.smartregister.fhircore.engine.app.AppConfigService import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.UniqueIdAssignmentConfig import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.event.EventTriggerCondition @@ -106,6 +110,7 @@ import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.loadResource import org.smartregister.fhircore.engine.util.extension.plusDays import org.smartregister.fhircore.engine.util.extension.updateLastUpdated +import org.smartregister.fhircore.engine.util.extension.yesterday import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor @HiltAndroidTest @@ -134,6 +139,9 @@ class DefaultRepositoryTest : RobolectricTest() { private lateinit var dispatcherProvider: DefaultDispatcherProvider private lateinit var sharedPreferenceHelper: SharedPreferencesHelper private lateinit var defaultRepository: DefaultRepository + private lateinit var patient: Patient + private lateinit var questionnaireConfig: QuestionnaireConfig + private lateinit var samplePatientRegisterQuestionnaire: Questionnaire @Before fun setUp() { @@ -153,6 +161,25 @@ class DefaultRepositoryTest : RobolectricTest() { context = context, contentCache = contentCache, ) + patient = + Faker.buildPatient().apply { + address = + listOf( + Address().apply { + city = "Mombasa" + country = "Kenya" + }, + ) + } + questionnaireConfig = + QuestionnaireConfig( + id = "e5155788-8831-4916-a3f5-486915ce34b211", // Same as ID in + // sample_patient_registration.json + title = "Patient registration", + type = "DEFAULT", + ) + + samplePatientRegisterQuestionnaire = Questionnaire().apply { id = questionnaireConfig.id } } @Test @@ -1620,4 +1647,110 @@ class DefaultRepositoryTest : RobolectricTest() { Assert.assertEquals(2, location4SubLocations.size) Assert.assertEquals(location5.logicalId, location4SubLocations.last().logicalId) } + + @Test + fun testSearchLatestQuestionnaireResponseShouldReturnLatestQuestionnaireResponse() = + runTest(timeout = 90.seconds) { + val sampleEncounter = + Encounter().apply { + id = "encounter-id-1" + subject = patient.asReference() + } + Assert.assertNull( + defaultRepository.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = sampleEncounter.id, + ), + ) + + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "qr1" + meta.lastUpdated = Date() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + encounter = sampleEncounter.asReference() + }, + QuestionnaireResponse().apply { + id = "qr2" + meta.lastUpdated = yesterday() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + }, + ) + + // Add QuestionnaireResponse to database + fhirEngine.create( + patient, + samplePatientRegisterQuestionnaire, + *questionnaireResponses.toTypedArray(), + ) + + val latestQuestionnaireResponse = + defaultRepository.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = sampleEncounter.id, + ) + Assert.assertNotNull(latestQuestionnaireResponse) + Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) + Assert.assertEquals( + "Encounter/encounter-id-1", + latestQuestionnaireResponse?.encounter?.reference, + ) + } + + @Test + fun testSearchLatestQuestionnaireResponseWhenSaveDraftIsTrueShouldReturnLatestQuestionnaireResponse() = + runTest(timeout = 90.seconds) { + Assert.assertNull( + defaultRepository.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), + ), + ) + + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "qr1" + meta.lastUpdated = Date() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + status = QuestionnaireResponseStatus.INPROGRESS + }, + QuestionnaireResponse().apply { + id = "qr2" + meta.lastUpdated = yesterday() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + status = QuestionnaireResponseStatus.COMPLETED + }, + ) + + // Add QuestionnaireResponse to database + fhirEngine.create( + patient, + samplePatientRegisterQuestionnaire, + *questionnaireResponses.toTypedArray(), + ) + + val latestQuestionnaireResponse = + defaultRepository.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), + ) + Assert.assertNotNull(latestQuestionnaireResponse) + Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt index 401ef8cc2c..bc0aeead93 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt @@ -53,10 +53,16 @@ class AlertDialogueTest : ActivityRobolectricTest() { alertIntent = AlertIntent.ERROR, message = getString(R.string.questionnaire_alert_invalid_message), title = getString(R.string.questionnaire_alert_invalid_title), - confirmButtonText = R.string.questionnaire_alert_confirm_button_title, - confirmButtonListener = { confirmCalled.add(true) }, - neutralButtonText = R.string.questionnaire_alert_ack_button_title, - neutralButtonListener = { neutralCalled.add(true) }, + confirmButton = + AlertDialogButton( + text = R.string.questionnaire_alert_confirm_button_title, + listener = { confirmCalled.add(true) }, + ), + neutralButton = + AlertDialogButton( + text = R.string.questionnaire_alert_ack_button_title, + listener = { neutralCalled.add(true) }, + ), options = arrayOf(AlertDialogListItem("a", "A"), AlertDialogListItem("b", "B")), ) @@ -143,16 +149,25 @@ class AlertDialogueTest : ActivityRobolectricTest() { @Test fun testShowCancelAlertShowsWithCorrectData() { - AlertDialogue.showCancelAlert( + AlertDialogue.showThreeButtonAlert( context = context, message = R.string.questionnaire_in_progress_alert_back_pressed_message, title = R.string.questionnaire_alert_back_pressed_title, - confirmButtonListener = {}, - confirmButtonText = R.string.questionnaire_alert_back_pressed_save_draft_button_title, - neutralButtonListener = {}, - neutralButtonText = R.string.questionnaire_alert_back_pressed_button_title, - negativeButtonListener = {}, - negativeButtonText = R.string.questionnaire_alert_negative_button_title, + confirmButton = + AlertDialogButton( + listener = {}, + text = R.string.questionnaire_alert_back_pressed_save_draft_button_title, + ), + neutralButton = + AlertDialogButton( + listener = {}, + text = R.string.questionnaire_alert_back_pressed_button_title, + ), + negativeButton = + AlertDialogButton( + listener = {}, + text = R.string.questionnaire_alert_negative_button_title, + ), ) val dialog = shadowOf(ShadowAlertDialog.getLatestAlertDialog()) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt index a3ff11c5cc..989237f4f9 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt @@ -70,5 +70,10 @@ sealed class MainNavigationScreen( route = org.smartregister.fhircore.quest.R.id.summaryBottomSheetFragment, ) + data object AlertDialogFragment : + MainNavigationScreen( + route = org.smartregister.fhircore.quest.R.id.questionnaireDraftDialogFragment, + ) + fun eventId(id: String) = route.toString() + "_" + id } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt index 618bf1823b..7842917831 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt @@ -28,4 +28,5 @@ object NavigationArg { const val REPORT_ID = "reportId" const val PARAMS = "params" const val TOOL_BAR_HOME_NAVIGATION = "toolBarHomeNavigation" + const val QUESTIONNAIRE_CONFIG = "questionnaireConfig" } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt index 2b4d302b1e..2eab074a82 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt @@ -53,6 +53,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager +import org.smartregister.fhircore.engine.ui.base.AlertDialogButton import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.theme.AppTheme @@ -271,15 +272,21 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { alertIntent = AlertIntent.INFO, message = geoWidgetConfiguration.noResults?.message!!, title = geoWidgetConfiguration.noResults?.title!!, - confirmButtonListener = { - geoWidgetConfiguration.noResults - ?.actionButton - ?.actions - ?.handleClickEvent(findNavController()) - }, - confirmButtonText = R.string.positive_button_location_set, + confirmButton = + AlertDialogButton( + listener = { + geoWidgetConfiguration.noResults + ?.actionButton + ?.actions + ?.handleClickEvent(findNavController()) + }, + text = R.string.positive_button_location_set, + ), cancellable = true, - neutralButtonListener = {}, + neutralButton = + AlertDialogButton( + listener = {}, + ), ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index c1c47cdbfd..3a239198b1 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -52,6 +52,7 @@ import org.smartregister.fhircore.engine.domain.model.LauncherType import org.smartregister.fhircore.engine.rulesengine.services.LocationCoordinate import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager +import org.smartregister.fhircore.engine.ui.base.AlertDialogButton import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity @@ -318,8 +319,14 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, title = getString(R.string.exit_app), message = getString(R.string.exit_app_message), cancellable = false, - confirmButtonListener = { finish() }, - neutralButtonListener = { dialog -> dialog.dismiss() }, + confirmButton = + AlertDialogButton( + listener = { finish() }, + ), + neutralButton = + AlertDialogButton( + listener = { dialog -> dialog.dismiss() }, + ), ) } else navHostFragment.navController.navigateUp() } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index 2bb53a31e0..e0608a8062 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -50,6 +50,7 @@ import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.isReadOnly import org.smartregister.fhircore.engine.domain.model.isSummary +import org.smartregister.fhircore.engine.ui.base.AlertDialogButton import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity import org.smartregister.fhircore.engine.util.DispatcherProvider @@ -351,29 +352,40 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { if (questionnaireConfig.isReadOnly()) { finish() } else if (questionnaireConfig.saveDraft) { - AlertDialogue.showCancelAlert( + AlertDialogue.showThreeButtonAlert( context = this, message = org.smartregister.fhircore.engine.R.string .questionnaire_in_progress_alert_back_pressed_message, title = org.smartregister.fhircore.engine.R.string.questionnaire_alert_back_pressed_title, - confirmButtonListener = { - lifecycleScope.launch { - retrieveQuestionnaireResponse()?.let { questionnaireResponse -> - viewModel.saveDraftQuestionnaire(questionnaireResponse, questionnaireConfig) - finish() - } - } - }, - confirmButtonText = - org.smartregister.fhircore.engine.R.string - .questionnaire_alert_back_pressed_save_draft_button_title, - neutralButtonListener = {}, - neutralButtonText = - org.smartregister.fhircore.engine.R.string.questionnaire_alert_neutral_button_title, - negativeButtonListener = { finish() }, - negativeButtonText = - org.smartregister.fhircore.engine.R.string.questionnaire_alert_negative_button_title, + confirmButton = + AlertDialogButton( + listener = { + lifecycleScope.launch { + retrieveQuestionnaireResponse()?.let { questionnaireResponse -> + viewModel.saveDraftQuestionnaire(questionnaireResponse, questionnaireConfig) + finish() + } + } + }, + text = + org.smartregister.fhircore.engine.R.string + .questionnaire_alert_back_pressed_save_draft_button_title, + color = org.smartregister.fhircore.engine.R.color.colorPrimary, + ), + neutralButton = + AlertDialogButton( + listener = {}, + text = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_neutral_button_title, + ), + negativeButton = + AlertDialogButton( + listener = { finish() }, + text = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_negative_button_title, + color = org.smartregister.fhircore.engine.R.color.colorPrimary, + ), ) } else { AlertDialogue.showConfirmAlert( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogFragment.kt new file mode 100644 index 0000000000..7e6e277005 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogFragment.kt @@ -0,0 +1,81 @@ +/* + * 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.quest.ui.questionnaire + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.runBlocking +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.ui.base.AlertDialogButton +import org.smartregister.fhircore.engine.ui.base.AlertDialogue +import org.smartregister.fhircore.engine.util.extension.getActivity +import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler + +@AndroidEntryPoint +class QuestionnaireDraftDialogFragment() : DialogFragment() { + + private val questionnaireDraftDialogFragmentArgs by + navArgs() + private val questionnaireDraftDialogViewModel by viewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return AlertDialogue.showThreeButtonAlert( + context = requireContext(), + message = org.smartregister.fhircore.engine.R.string.open_draft_changes_message, + title = org.smartregister.fhircore.engine.R.string.open_draft_changes_title, + confirmButton = + AlertDialogButton( + listener = { + if (requireContext().getActivity() is QuestionnaireHandler) { + (requireContext().getActivity() as QuestionnaireHandler).launchQuestionnaire( + context = requireContext().getActivity()!!.baseContext, + questionnaireConfig = questionnaireDraftDialogFragmentArgs.questionnaireConfig, + actionParams = listOf(), + ) + } + }, + text = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_open_draft_button_title, + color = R.color.colorPrimary, + ), + neutralButton = + AlertDialogButton( + listener = {}, + text = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_neutral_button_title, + ), + negativeButton = + AlertDialogButton( + listener = { + runBlocking { + questionnaireDraftDialogViewModel.deleteDraft( + questionnaireDraftDialogFragmentArgs.questionnaireConfig, + ) + } + }, + text = + org.smartregister.fhircore.engine.R.string + .questionnaire_alert_delete_draft_button_title, + color = R.color.colorError, + ), + ) + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModel.kt new file mode 100644 index 0000000000..360a74b426 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModel.kt @@ -0,0 +1,126 @@ +/* + * 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.quest.ui.questionnaire + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.Date +import javax.inject.Inject +import org.hl7.fhir.r4.model.AuditEvent +import org.hl7.fhir.r4.model.AuditEvent.AuditEventSourceComponent +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Period +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseStatus +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid + +@HiltViewModel +class QuestionnaireDraftDialogViewModel +@Inject +constructor( + val defaultRepository: DefaultRepository, + val sharedPreferencesHelper: SharedPreferencesHelper, +) : ViewModel() { + + private val practitionerId: String? by lazy { + sharedPreferencesHelper + .read(SharedPreferenceKey.PRACTITIONER_ID.name, null) + ?.extractLogicalIdUuid() + } + + suspend fun deleteDraft(questionnaireConfig: QuestionnaireConfig?) { + if ( + questionnaireConfig == null || + questionnaireConfig.resourceIdentifier.isNullOrBlank() || + questionnaireConfig.resourceType == null + ) { + return + } + + val questionnaireResponse = + defaultRepository.searchQuestionnaireResponse( + resourceId = questionnaireConfig.resourceIdentifier!!, + resourceType = questionnaireConfig.resourceType!!, + questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), + ) + + if (questionnaireResponse != null) { + questionnaireResponse.status = QuestionnaireResponseStatus.STOPPED + defaultRepository.applyDbTransaction { + defaultRepository.update(questionnaireResponse) + defaultRepository.addOrUpdate( + resource = createDeleteDraftAuditEvent(questionnaireConfig, questionnaireResponse), + ) + } + } + } + + fun createDeleteDraftAuditEvent( + questionnaireConfig: QuestionnaireConfig, + questionnaireResponse: QuestionnaireResponse, + ): AuditEvent { + return AuditEvent().apply { + entity = + listOf( + AuditEvent.AuditEventEntityComponent().apply { + what = Reference(questionnaireResponse.id) + }, + ) + source = + AuditEventSourceComponent().apply { + observer = + questionnaireConfig.resourceType?.let { + questionnaireConfig.resourceIdentifier?.asReference( + it, + ) + } + } + agent = + listOf( + AuditEvent.AuditEventAgentComponent().apply { + who = practitionerId?.asReference(ResourceType.Practitioner) + }, + ) + type = + Coding().apply { + system = AUDIT_EVENT_SYSTEM + code = AUDIT_EVENT_CODE + display = AUDIT_EVENT_DISPLAY + } + period = + Period().apply { + start = Date() + end = Date() + } + } + } + + companion object { + const val AUDIT_EVENT_SYSTEM = "http://smartregister.org/" + const val AUDIT_EVENT_CODE = "delete_draft" + const val AUDIT_EVENT_DISPLAY = "Delete Draft" + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt index b2430fe56e..b6f8592318 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt @@ -571,7 +571,8 @@ constructor( !questionnaireConfig.resourceIdentifier.isNullOrEmpty() && subjectType != null ) { - searchQuestionnaireResponse( + defaultRepository + .searchQuestionnaireResponse( resourceId = questionnaireConfig.resourceIdentifier!!, resourceType = questionnaireConfig.resourceType ?: subjectType, questionnaireId = questionnaire.logicalId, @@ -1064,48 +1065,6 @@ constructor( } } - /** - * 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. - */ - 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 = defaultRepository.search(search) - return questionnaireResponses.maxByOrNull { it.meta.lastUpdated } - } - private suspend fun launchContextResources( subjectResourceType: ResourceType?, subjectResourceIdentifier: String?, @@ -1176,7 +1135,8 @@ constructor( questionnaireConfig.isReadOnly() || questionnaireConfig.saveDraft) ) { - searchQuestionnaireResponse( + defaultRepository + .searchQuestionnaireResponse( resourceId = resourceIdentifier, resourceType = resourceType, questionnaireId = questionnaire.logicalId, @@ -1186,6 +1146,7 @@ constructor( ?.let { QuestionnaireResponse().apply { id = it.id + status = it.status item = it.item.removeUnAnsweredItems() // Clearing the text prompts the SDK to re-process the content, which includes HTML clearText() diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index 19e1e44dbf..756274dcbe 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -236,6 +236,15 @@ fun ActionConfig.handleClickEvent( val appCompatActivity = (navController.context as AppCompatActivity) PdfLauncherFragment.launch(appCompatActivity, interpolatedPdfConfig.encodeJson()) } + ApplicationWorkflow.DELETE_DRAFT_QUESTIONNAIRE -> { + val questionnaireConfigInterpolated = + actionConfig.questionnaire?.interpolate(computedValuesMap) + val args = + bundleOf( + NavigationArg.QUESTIONNAIRE_CONFIG to questionnaireConfigInterpolated, + ) + navController.navigate(MainNavigationScreen.AlertDialogFragment.route, args) + } else -> return } } diff --git a/android/quest/src/main/res/navigation/application_nav_graph.xml b/android/quest/src/main/res/navigation/application_nav_graph.xml index 21bf25e2a1..3c77f01c8e 100644 --- a/android/quest/src/main/res/navigation/application_nav_graph.xml +++ b/android/quest/src/main/res/navigation/application_nav_graph.xml @@ -110,4 +110,12 @@ android:name="org.smartregister.fhircore.quest.ui.bottomsheet.SummaryBottomSheetFragment" > + + + diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt new file mode 100644 index 0000000000..14fbdcfae7 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt @@ -0,0 +1,197 @@ +/* + * 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.quest.ui.questionnaire + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.parser.IParser +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.get +import com.google.android.fhir.search.Search +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.mockk +import io.mockk.spyk +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.AuditEvent +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.ResourceType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.data.local.ContentCache +import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +import org.smartregister.fhircore.quest.app.fakes.Faker +import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireDraftDialogViewModel.Companion.AUDIT_EVENT_CODE +import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireDraftDialogViewModel.Companion.AUDIT_EVENT_DISPLAY +import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireDraftDialogViewModel.Companion.AUDIT_EVENT_SYSTEM + +@HiltAndroidTest +class QuestionnaireDraftDialogViewModelTest : RobolectricTest() { + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + + @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper + + @Inject lateinit var configService: ConfigService + + @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + + @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor + + @Inject lateinit var fhirEngine: FhirEngine + + @Inject lateinit var dispatcherProvider: DispatcherProvider + + @Inject lateinit var parser: IParser + + @Inject lateinit var contentCache: ContentCache + + private lateinit var questionnaireDraftDialogViewModel: QuestionnaireDraftDialogViewModel + lateinit var defaultRepository: DefaultRepository + private val configurationRegistry = Faker.buildTestConfigurationRegistry() + private val context: Application = ApplicationProvider.getApplicationContext() + private val configRulesExecutor: ConfigRulesExecutor = mockk() + lateinit var questionnaireConfig: QuestionnaireConfig + lateinit var questionnaireResponse: QuestionnaireResponse + private val practitionerId = "practitioner-id-1" + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + hiltRule.inject() + // Write practitioner and organization to shared preferences + sharedPreferencesHelper.write( + SharedPreferenceKey.PRACTITIONER_ID.name, + practitionerId, + ) + defaultRepository = + spyk( + DefaultRepository( + fhirEngine = fhirEngine, + dispatcherProvider = dispatcherProvider, + sharedPreferencesHelper = sharedPreferencesHelper, + configurationRegistry = configurationRegistry, + configService = configService, + configRulesExecutor = configRulesExecutor, + fhirPathDataExtractor = fhirPathDataExtractor, + parser = parser, + context = context, + contentCache = contentCache, + ), + ) + questionnaireDraftDialogViewModel = + spyk( + QuestionnaireDraftDialogViewModel( + defaultRepository = defaultRepository, + sharedPreferencesHelper = sharedPreferencesHelper, + ), + ) + + questionnaireConfig = + QuestionnaireConfig( + id = "dc-clinic-medicines", + resourceType = ResourceType.Patient, + resourceIdentifier = "Patient-id-1", + ) + questionnaireResponse = + QuestionnaireResponse().apply { + id = "qr-id-1" + status = QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS + subject = "Patient-id-1".asReference(ResourceType.Patient) + questionnaire = "Questionnaire/dc-clinic-medicines" + } + } + + @Test + fun testDeleteDraftUpdateQuestionnaireResponseStatusToStoppedAndAuditEvent() { + runTest(timeout = 90.seconds) { + // add QR to db + fhirEngine.create(questionnaireResponse) + val savedDraft = fhirEngine.get("qr-id-1") + assertEquals("QuestionnaireResponse/qr-id-1", savedDraft.id) + assertEquals("Patient/Patient-id-1", savedDraft.subject.reference) + assertEquals("Questionnaire/dc-clinic-medicines", savedDraft.questionnaire) + assertEquals("in-progress", savedDraft.status.toCode()) + + runBlocking { + questionnaireDraftDialogViewModel.deleteDraft(questionnaireConfig = questionnaireConfig) + } + + val deletedDraft = fhirEngine.get("qr-id-1") + assertEquals("QuestionnaireResponse/qr-id-1", deletedDraft.id) + assertEquals("Patient/Patient-id-1", deletedDraft.subject.reference) + assertEquals("Questionnaire/dc-clinic-medicines", deletedDraft.questionnaire) + assertEquals("stopped", deletedDraft.status.toCode()) + + val search = + Search(ResourceType.AuditEvent).apply { + filter( + AuditEvent.SOURCE, + { value = "Patient-id-1".asReference(ResourceType.Patient).reference }, + ) + filter( + AuditEvent.TYPE, + { value = of("delete_draft") }, + ) + } + + val createdAuditEventList = defaultRepository.search(search) + assertNotNull(createdAuditEventList) + assertEquals( + "QuestionnaireResponse/qr-id-1", + createdAuditEventList[0].entity[0].what.reference, + ) + assertEquals( + "Practitioner/practitioner-id-1", + createdAuditEventList[0].agent[0].who.reference, + ) + assertEquals("Patient/Patient-id-1", createdAuditEventList[0].source.observer.reference) + } + } + + @Test + fun testCreateDeleteDraftFlag() { + val auditEvent = + questionnaireDraftDialogViewModel.createDeleteDraftAuditEvent( + questionnaireConfig = questionnaireConfig, + questionnaireResponse = questionnaireResponse, + ) + + assertEquals("Patient/Patient-id-1", auditEvent.source.observer.reference) + assertEquals("Practitioner/practitioner-id-1", auditEvent.agent[0].who.reference) + assertEquals(AUDIT_EVENT_SYSTEM, auditEvent.type.system) + assertEquals(AUDIT_EVENT_CODE, auditEvent.type.code) + assertEquals(AUDIT_EVENT_DISPLAY, auditEvent.type.display) + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt index 2360f2061c..e2b95eaaa4 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt @@ -1375,102 +1375,6 @@ class QuestionnaireViewModelTest : RobolectricTest() { coVerify { defaultRepository.addOrUpdate(true, patient) } } - @Test - fun testSearchLatestQuestionnaireResponseShouldReturnLatestQuestionnaireResponse() = - runTest(timeout = 90.seconds) { - Assert.assertNull( - questionnaireViewModel.searchQuestionnaireResponse( - resourceId = patient.logicalId, - resourceType = ResourceType.Patient, - questionnaireId = questionnaireConfig.id, - encounterId = null, - ), - ) - - val questionnaireResponses = - listOf( - QuestionnaireResponse().apply { - id = "qr1" - meta.lastUpdated = Date() - subject = patient.asReference() - questionnaire = samplePatientRegisterQuestionnaire.asReference().reference - }, - QuestionnaireResponse().apply { - id = "qr2" - meta.lastUpdated = yesterday() - subject = patient.asReference() - questionnaire = samplePatientRegisterQuestionnaire.asReference().reference - }, - ) - - // Add QuestionnaireResponse to database - fhirEngine.create( - patient, - samplePatientRegisterQuestionnaire, - *questionnaireResponses.toTypedArray(), - ) - - val latestQuestionnaireResponse = - questionnaireViewModel.searchQuestionnaireResponse( - resourceId = patient.logicalId, - resourceType = ResourceType.Patient, - questionnaireId = questionnaireConfig.id, - encounterId = null, - ) - Assert.assertNotNull(latestQuestionnaireResponse) - Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) - } - - @Test - fun testSearchLatestQuestionnaireResponseWhenSaveDraftIsTueShouldReturnLatestQuestionnaireResponse() = - runTest(timeout = 90.seconds) { - Assert.assertNull( - questionnaireViewModel.searchQuestionnaireResponse( - resourceId = patient.logicalId, - resourceType = ResourceType.Patient, - questionnaireId = questionnaireConfig.id, - encounterId = null, - questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), - ), - ) - - val questionnaireResponses = - listOf( - QuestionnaireResponse().apply { - id = "qr1" - meta.lastUpdated = Date() - subject = patient.asReference() - questionnaire = samplePatientRegisterQuestionnaire.asReference().reference - status = QuestionnaireResponseStatus.INPROGRESS - }, - QuestionnaireResponse().apply { - id = "qr2" - meta.lastUpdated = yesterday() - subject = patient.asReference() - questionnaire = samplePatientRegisterQuestionnaire.asReference().reference - status = QuestionnaireResponseStatus.COMPLETED - }, - ) - - // Add QuestionnaireResponse to database - fhirEngine.create( - patient, - samplePatientRegisterQuestionnaire, - *questionnaireResponses.toTypedArray(), - ) - - val latestQuestionnaireResponse = - questionnaireViewModel.searchQuestionnaireResponse( - resourceId = patient.logicalId, - resourceType = ResourceType.Patient, - questionnaireId = questionnaireConfig.id, - encounterId = null, - questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), - ) - Assert.assertNotNull(latestQuestionnaireResponse) - Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) - } - @Test fun testRetrievePopulationResourcesReturnsListOfResourcesOrEmptyList() = runTest { val specimenId = "specimenId" @@ -1628,7 +1532,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { } coEvery { - questionnaireViewModel.searchQuestionnaireResponse( + defaultRepository.searchQuestionnaireResponse( resourceId = patient.logicalId, resourceType = ResourceType.Patient, questionnaireId = questionnaireConfig.id, diff --git a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx index c9d33123c4..a6457a835b 100644 --- a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx +++ b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx @@ -16,19 +16,25 @@ This excludes forms such as "register client" or "register household". - A health care worker is doing a household visit and providing care to multiple household members. They want the ability to start a workflow and switch to another workflow without losing their data - A health care worker is required to collect data in both the app and on paper. They start a form in the app, but are under time pressure, so they fill out the paper form and plan to enter the data in the app later - +The save draft functionality can be configured using the `LAUNCH_QUESTIONNAIRE` or the `DELETE_DRAFT_QUESTIONNAIRE` workflow. The configuration is done on the `QuestionnaireConfig`. The sample below demonstrates the configs that are required in order to save a form as a draft ```json { - "questionnaire": { - "id": "add-family-member", - "title": "Add Family Member", - "resourceIdentifier": "sample-house-id", - "resourceType": "Group", - "saveDraft": true - } + "actions": [ + { + "trigger": "ON_CLICK", + "workflow": "LAUNCH_QUESTIONNAIRE", + "questionnaire": { + "id": "add-family-member", + "title": "Add Family Member", + "resourceIdentifier": "sample-house-id", + "resourceType": "Group", + "saveDraft": true + } + } + ] } ``` ## Config properties @@ -41,15 +47,107 @@ resourceIdentifier | Unique ID String for the subject of the form | resourceType | The String representation of the resource type for the subject of the form | yes | | saveDraft | Flag that determines whether the form can be saved as a draft | yes | false | -## UI/UX workflow +### UI/UX workflow for saving a form as draft When the form is opened, with the configurations in place, the save as draft functionality is triggered when the user clicks on the close button (X) at the top left of the screen. A dialog appears with 3 buttons i.e `Save as draft`, `Discard changes` and `Cancel`. The table below details what each of the buttons does. -### Alert dialog buttons descriptions +#### Alert dialog buttons descriptions |Button | Description | |--|--|:--:|:--:| Save as draft | Saves user input as a draft | Discard changes | Dismisses user input, and closes the form without saving the draft. | -Cancel | Dismisses the dialog so that the user can continue interacting with the form | \ No newline at end of file +Cancel | Dismisses the dialog so that the user can continue interacting with the form | + +## Launching save draft from DELETE_DRAFT_QUESTIONNAIRE workflow +The save draft functionality works the same as described above when launched using the `DELETE_DRAFT_QUESTIONNAIRE` workflow. +The workflow adds another dialog that allows the user to either open or delete the draft. +The sample below demonstrates the configs that are required in order to save a form as a draft and also delete the draft. +```json +{ + "actions": [ + { + "trigger": "ON_CLICK", + "workflow": "DELETE_DRAFT_QUESTIONNAIRE", + "questionnaire": { + "id": "add-family-member", + "title": "Add Family Member", + "resourceIdentifier": "sample-house-id", + "resourceType": "Group", + "saveDraft": true + } + } + ] +} +``` + +### UI/UX workflow for deleting a draft form +When the `DELETE_DRAFT_QUESTIONNAIRE` workflow is configured, a dialog appears when the call to action is triggered. +The dialog has 3 buttons i.e `Open draft`, `Delete draft` and `Cancel`. + +The table below details what each of the buttons does. + +|Button | Description | +|:--|:--| +Open draft | Opens the questionnaire pre-filled with the saved draft changes | +Delete draft | Does a soft delete of the draft i.e update the status of the `QuestionnaireResponse` to `stopped` | +Cancel | Dismisses the dialog | + +### Propagating deletes to other devices +Since the devices work offline, there is a chance that a draft that has been deleted on device A could have some local changes on a device B. +Due to the way conflict resolution works, at the moment, when device B syncs the changes that indicate the draft has been deleted will not reflect on device B. +With this in mind, Event Management is used to update the deleted drafts in the background. + +The following is a sample config that would be added to the `application_config.json` +``` +{ + "eventWorkflows": [ + { + "eventType": "RESOURCE_CLOSURE", + "triggerConditions": [ + ], + "eventResources": [ + { + "id": "draftFormToBeClosed", + "resource": "AuditEvent", + "dataQueries": [ + { + "paramName": "type", + "filterCriteria": [ + { + "dataType": "CODE", + "value": { + "system": "http://smartregister.org/", + "code": "delete_draft" + } + } + ] + } + ], + "relatedResources": [ + { + "resource": "QuestionnaireResponse", + "searchParameter": "entity", + "isRevInclude": false + } + ] + } + ], + "updateValues": [ + { + "jsonPathExpression": "QuestionnaireResponse.status", + "value": "stopped", + "resourceType": "QuestionnaireResponse" + } + ], + "resourceFilterExpressions": [] + } + ] +} +``` + +An `AuditEvent` resource is used to keep track of deleted drafts. It has a reference to the `QuestionnaireResponse` in the `entity` field. +The event management functionality fetches all the `AuditEvents` that have the `type` = `delete_draft`. +Then fetches the related `QuestionnaireResponses` by doing a forward include search on the `QuestionnaireResponse.entity` field. +The status for the retrieved `QuestionnaireResponses` is then updated to `stopped` i.e the draft is soft deleted. \ No newline at end of file From 996914e9942f59f3d3a520dc1561dff2f917a287 Mon Sep 17 00:00:00 2001 From: Fikri Milano Date: Wed, 4 Dec 2024 16:17:19 +0700 Subject: [PATCH 02/12] Disable rotation for QuestionnaireActivity (#3644) Co-authored-by: Rkareko <47570855+Rkareko@users.noreply.github.com> --- android/quest/src/main/AndroidManifest.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/quest/src/main/AndroidManifest.xml b/android/quest/src/main/AndroidManifest.xml index 46fe396c60..d403ae5834 100644 --- a/android/quest/src/main/AndroidManifest.xml +++ b/android/quest/src/main/AndroidManifest.xml @@ -61,7 +61,8 @@ + android:theme="@style/AppTheme" + android:screenOrientation="portrait" /> Date: Thu, 5 Dec 2024 00:44:59 +0300 Subject: [PATCH 03/12] Refactor implementation for loading data on repositories to improve performance (#3634) * Start register data refactor Signed-off-by: Elly Kitoto * Fix loading related resources data Signed-off-by: Elly Kitoto * Optimize import Signed-off-by: Elly Kitoto * Refactor implementation for counting related resources Signed-off-by: Elly Kitoto * Refactor implementation for loading profile data Signed-off-by: Elly Kitoto * Refactor the rest of implementation using DefaultRepository Signed-off-by: Elly Kitoto * Use coding system on REL filter query Signed-off-by: Elly Kitoto * Fix count with REL filter applied Signed-off-by: Elly Kitoto * Refactor retrieval of REL tag locationIds Signed-off-by: Elly Kitoto * Revert paging3 implementation Signed-off-by: Elly Kitoto * Set pager prefetch size to half of page size Signed-off-by: Elly Kitoto * Run spotless Signed-off-by: Elly Kitoto * Refactor code Signed-off-by: Elly Kitoto * Fix redundant count of related resources Signed-off-by: Elly Kitoto * Refactor implementation Signed-off-by: Elly Kitoto * Remove unnecessary coroutine context switching Signed-off-by: Elly Kitoto * Fix failing engine tests Signed-off-by: Elly Kitoto * Fix failing tests Signed-off-by: Elly Kitoto * Run spotlessApply Signed-off-by: Elly Kitoto * Run spotless Signed-off-by: Elly Kitoto * Fix failing lint check Signed-off-by: Elly Kitoto * Run spotlessApply Signed-off-by: Elly Kitoto * Fix failing lint check Signed-off-by: Elly Kitoto * Run spotlessApply Signed-off-by: Elly Kitoto * Fix failing tests Signed-off-by: Elly Kitoto * Run spotlessApply Signed-off-by: Elly Kitoto * Format code Signed-off-by: Elly Kitoto --------- Signed-off-by: Elly Kitoto --- .../migration/MigrationConfig.kt | 2 +- .../register/RegisterConfiguration.kt | 2 +- .../register/RegisterContentConfig.kt | 2 +- .../engine/data/local/DefaultRepository.kt | 1091 +++++++++-------- .../data/local/register/RegisterRepository.kt | 179 ++- .../fhircore/engine/di/CoreModule.kt | 19 + .../fhircore/engine/di/DispatcherModule.kt | 2 + .../fhircore/engine/di/NetworkModule.kt | 13 +- .../engine/domain/model/ActionConfig.kt | 2 +- .../domain/model/RepositoryResourceData.kt | 21 +- .../engine/domain/repository/Repository.kt | 6 +- .../engine/rulesengine/ConfigRulesExecutor.kt | 28 +- ...eDataRulesExecutor.kt => RulesExecutor.kt} | 32 +- .../engine/rulesengine/RulesFactory.kt | 42 +- .../engine/rulesengine/RulesListener.kt | 45 +- .../fhircore/engine/sync/SyncBroadcaster.kt | 4 +- .../fhircore/engine/task/FhirResourceUtil.kt | 4 +- .../util/extension/ApplicationExtension.kt | 32 + .../util/extension/FhirEngineExtension.kt | 35 +- .../util/extension/ResourceExtension.kt | 4 +- .../data/local/DefaultRepositoryTest.kt | 16 +- .../local/register/RegisterRepositoryTest.kt | 25 +- .../rulesengine/ConfigRulesExecutorTest.kt | 10 +- ...esExecutorTest.kt => RulesExecutorTest.kt} | 37 +- .../engine/rulesengine/RulesFactoryTest.kt | 43 +- .../engine/task/FhirCarePlanGeneratorTest.kt | 2 +- .../MeasureReportResultScreenTest.kt | 6 +- android/quest/src/main/AndroidManifest.xml | 1 + .../registers/household_register_config.json | 20 + .../fhircore/quest/QuestApplication.kt | 2 - .../fhircore/quest/data/DataMigration.kt | 29 +- .../data/geowidget/GeoWidgetPagingSource.kt | 114 -- .../data/register/RegisterPagingSource.kt | 52 +- .../model/RegisterPagingSourceState.kt | 3 + .../measure/MeasureReportPagingSource.kt | 11 +- .../report/measure/MeasureReportRepository.kt | 4 +- .../quest/ui/appsetting/AppSettingActivity.kt | 4 - .../ui/appsetting/AppSettingViewModel.kt | 3 +- .../geowidget/GeoWidgetLauncherViewModel.kt | 188 ++- .../fhircore/quest/ui/main/AppMainActivity.kt | 4 +- .../ui/multiselect/MultiSelectViewModel.kt | 22 +- .../quest/ui/profile/ProfileFragment.kt | 28 +- .../quest/ui/profile/ProfileViewModel.kt | 128 +- .../questionnaire/QuestionnaireViewModel.kt | 13 +- .../quest/ui/register/RegisterViewModel.kt | 138 +-- .../report/measure/MeasureReportViewModel.kt | 8 +- android/quest/src/main/res/values/strings.xml | 1 + .../fhircore/quest/data/DataMigrationTest.kt | 2 +- .../data/register/RegisterPagingSourceTest.kt | 132 +- .../measure/MeasureReportPagingSourceTest.kt | 12 +- .../measure/MeasureReportRepositoryTest.kt | 40 +- .../GeoWidgetLauncherViewModelTest.kt | 6 +- .../quest/ui/profile/ProfileFragmentTest.kt | 12 +- .../quest/ui/profile/ProfileViewModelTest.kt | 24 +- .../QuestionnaireDraftDialogViewModelTest.kt | 4 +- .../QuestionnaireViewModelTest.kt | 17 +- .../quest/ui/register/RegisterFragmentTest.kt | 2 +- .../ui/register/RegisterViewModelTest.kt | 12 +- .../measure/MeasureReportViewModelTest.kt | 6 +- 59 files changed, 1449 insertions(+), 1297 deletions(-) rename android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/{ResourceDataRulesExecutor.kt => RulesExecutor.kt} (92%) rename android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/{ResourceDataRulesExecutorTest.kt => RulesExecutorTest.kt} (94%) delete mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/data/geowidget/GeoWidgetPagingSource.kt 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/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt index d457ef68d9..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,6 +57,7 @@ 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 @@ -101,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( @@ -476,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, @@ -608,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) } } } @@ -675,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 @@ -757,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, @@ -813,6 +608,7 @@ constructor( } } } + repositoryResourceDataList.clear() } } @@ -831,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() } } } @@ -946,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, ) - count += DEFAULT_BATCH_SIZE - pageNumber++ - if (currentPage != null && pageSize != null) { - val maxPageCount = (currentPage + 1) * pageSize - if (searchResults.size >= maxPageCount) break - } } + } + forwardIncludes.forEach { resourceConfig -> + include( + resourceConfig.resource, + ReferenceClientParam(resourceConfig.searchParameter), + ) { + (this as Search).applyConfiguredSortAndFilters( + resourceConfig = resourceConfig, + sortData = true, + configComputedRuleValues = configComputedRuleValues, + ) + } + } + } + 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) { @@ -1201,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( @@ -1270,29 +1297,46 @@ 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 } + } /** * This function searches and returns the latest [QuestionnaireResponse] for the given @@ -1336,18 +1380,9 @@ constructor( return questionnaireResponses.maxByOrNull { it.meta.lastUpdated } } - /** - * 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. - */ - data class RelatedResourceWrapper( - val relatedResourceMap: MutableMap> = mutableMapOf(), - val relatedResourceCountMap: MutableMap> = - mutableMapOf(), - ) - 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/SyncBroadcaster.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt index 7eb2ab584f..134e1df08f 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 @@ -108,7 +108,9 @@ constructor( onSyncListener.onSync( if (it.lastSyncJobStatus != 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/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/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..fe502ab93e 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 @@ -547,11 +547,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/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt index 04495a7962..54f05cc8b2 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt @@ -1608,7 +1608,7 @@ class DefaultRepositoryTest : RobolectricTest() { } @Test - fun testRetrieveFlattenedSubLocationsShouldReturnCorrectLocations() = + fun testRetrieveFlattenedSubLocationsShouldReturnCorrectLocationIds() = runTest(timeout = 120.seconds) { val location1 = Location().apply { id = "loc1" } val location2 = @@ -1635,17 +1635,17 @@ class DefaultRepositoryTest : RobolectricTest() { fhirEngine.create(location1, location2, location3, location4, location5, isLocalOnly = true) val location1SubLocations = - defaultRepository.retrieveFlattenedSubLocations(location1.logicalId) + defaultRepository.retrieveFlattenedSubLocationIds(listOf(location1.logicalId)) Assert.assertEquals(5, location1SubLocations.size) - Assert.assertEquals(location2.logicalId, location1SubLocations[1].logicalId) - Assert.assertEquals(location3.logicalId, location1SubLocations[2].logicalId) - Assert.assertEquals(location4.logicalId, location1SubLocations[3].logicalId) - Assert.assertEquals(location5.logicalId, location1SubLocations[4].logicalId) + Assert.assertTrue(location1SubLocations.contains(location2.logicalId)) + Assert.assertTrue(location1SubLocations.contains(location3.logicalId)) + Assert.assertTrue(location1SubLocations.contains(location4.logicalId)) + Assert.assertTrue(location1SubLocations.contains(location5.logicalId)) val location4SubLocations = - defaultRepository.retrieveFlattenedSubLocations(location4.logicalId) + defaultRepository.retrieveFlattenedSubLocationIds(listOf(location4.logicalId)) Assert.assertEquals(2, location4SubLocations.size) - Assert.assertEquals(location5.logicalId, location4SubLocations.last().logicalId) + Assert.assertEquals(location5.logicalId, location4SubLocations.last()) } @Test diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt index 9cfc3af192..632f686698 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt @@ -67,6 +67,7 @@ import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule +import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor import org.smartregister.fhircore.engine.rulesengine.RulesFactory import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.asReference @@ -79,6 +80,7 @@ private const val HOUSEHOLD_REGISTER_ID = "householdRegister" private const val GROUP_ID = "theGroup" private const val GROUP_MEMBERS = "groupMembers" private const val ALL_TASKS = "allTasks" +private const val SUB_TASK = "subTask" private const val TASK_ID = "taskId" private const val PART_OF_TASK_ID = "partOfTaskId" private const val MEMBER_CARE_PLANS = "memberCarePlans" @@ -108,6 +110,8 @@ class RegisterRepositoryTest : RobolectricTest() { @Inject lateinit var contentCache: ContentCache + @Inject lateinit var configRulesExecutor: ConfigRulesExecutor + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private val patient = Faker.buildPatient(PATIENT_ID) private lateinit var registerRepository: RegisterRepository @@ -123,7 +127,7 @@ class RegisterRepositoryTest : RobolectricTest() { sharedPreferencesHelper = mockk(), configurationRegistry = configurationRegistry, configService = mockk(), - configRulesExecutor = mockk(), + configRulesExecutor = configRulesExecutor, fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = ApplicationProvider.getApplicationContext(), @@ -320,7 +324,7 @@ class RegisterRepositoryTest : RobolectricTest() { fun testLoadProfileDataWithForwardAndReverseIncludedResources() = runTest(timeout = 120.seconds) { val profileId = "profile" - every { registerRepository.retrieveProfileConfiguration(profileId, emptyMap()) } returns + val profileConfiguration = ProfileConfiguration( appId = "app", id = profileId, @@ -341,6 +345,10 @@ class RegisterRepositoryTest : RobolectricTest() { ), ), ) + configurationRegistry.configsJsonMap[profileId] = profileConfiguration.encodeJson() + configurationRegistry.configCacheMap[profileId] = profileConfiguration + every { registerRepository.retrieveProfileConfiguration(profileId, emptyMap()) } returns + profileConfiguration val group = createGroup(id = GROUP_ID, active = true, members = listOf(patient)) val carePlan = createCarePlan(id = "carePlan", subject = patient.asReference()) @@ -387,16 +395,19 @@ class RegisterRepositoryTest : RobolectricTest() { profileId = profileId, resourceId = GROUP_ID, fhirResourceConfig = null, - paramsList = null, + paramsMap = null, ) - Assert.assertTrue(repositoryResourceData.resource is Group) - Assert.assertEquals(GROUP_ID, repositoryResourceData.resource.logicalId) - Assert.assertTrue((repositoryResourceData.resource as Group).member.isNotEmpty()) + val resource = repositoryResourceData?.resource + Assert.assertNotNull(resource) + Assert.assertTrue(resource is Group) + Assert.assertEquals(GROUP_ID, resource?.logicalId) + Assert.assertTrue((resource as Group).member.isNotEmpty()) // Ensure the related resources were included val relatedResources = repositoryResourceData.relatedResourcesMap Assert.assertTrue(relatedResources.isNotEmpty()) Assert.assertTrue(relatedResources.containsKey(GROUP_MEMBERS)) + Assert.assertTrue(relatedResources.containsKey(SUB_TASK)) Assert.assertTrue(relatedResources.containsKey(ALL_TASKS)) Assert.assertTrue(relatedResources.containsKey(MEMBER_CARE_PLANS)) @@ -488,7 +499,7 @@ class RegisterRepositoryTest : RobolectricTest() { relatedResources = listOf( ResourceConfig( - id = ALL_TASKS, // Referenced task + id = SUB_TASK, // Referenced task resource = ResourceType.Task, searchParameter = PART_OF, isRevInclude = false, diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutorTest.kt index 78760f75f7..3cd17cfa3b 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutorTest.kt @@ -26,6 +26,7 @@ import io.mockk.slot import io.mockk.spyk import io.mockk.verify import javax.inject.Inject +import org.apache.commons.jexl3.JexlEngine import org.jeasy.rules.api.Facts import org.jeasy.rules.api.Rules import org.jeasy.rules.core.DefaultRulesEngine @@ -41,16 +42,19 @@ import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor @HiltAndroidTest class ConfigRulesExecutorTest : RobolectricTest() { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) - private lateinit var configRulesExecutor: ConfigRulesExecutor @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor + + @Inject lateinit var jexlEngine: JexlEngine + + private lateinit var configRulesExecutor: ConfigRulesExecutor private val rulesEngine = mockk() @Before @kotlinx.coroutines.ExperimentalCoroutinesApi fun setUp() { hiltRule.inject() - configRulesExecutor = spyk(ConfigRulesExecutor(fhirPathDataExtractor)) + configRulesExecutor = spyk(ConfigRulesExecutor(fhirPathDataExtractor, jexlEngine)) } @Test @@ -67,7 +71,7 @@ class ConfigRulesExecutorTest : RobolectricTest() { ReflectionHelpers.setField(configRulesExecutor, "rulesEngine", rulesEngine) every { rulesEngine.fire(any(), any()) } just runs val rules = configRulesExecutor.generateRules(ruleConfigs) - configRulesExecutor.fireRules(rules) + configRulesExecutor.computeConfigRules(rules, null) val factsSlot = slot() val rulesSlot = slot() verify { rulesEngine.fire(capture(rulesSlot), capture(factsSlot)) } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutorTest.kt similarity index 94% rename from android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutorTest.kt rename to android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutorTest.kt index 1a0626c36d..5ac7f4083f 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesExecutorTest.kt @@ -25,10 +25,12 @@ import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.mockk import io.mockk.spyk import java.util.LinkedList +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import org.apache.commons.jexl3.JexlEngine import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -56,7 +58,7 @@ import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor @HiltAndroidTest -class ResourceDataRulesExecutorTest : RobolectricTest() { +class RulesExecutorTest : RobolectricTest() { @get:Rule(order = 0) val hiltAndroidRule = HiltAndroidRule(this) @kotlinx.coroutines.ExperimentalCoroutinesApi @@ -68,11 +70,14 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var locationService: LocationService - private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() - private lateinit var rulesFactory: RulesFactory - private lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor @Inject lateinit var fhirContext: FhirContext + + @Inject lateinit var jexlEngine: JexlEngine + + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + private lateinit var rulesFactory: RulesFactory + private lateinit var rulesExecutor: RulesExecutor private lateinit var defaultRepository: DefaultRepository @Before @@ -89,10 +94,11 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { dispatcherProvider = dispatcherProvider, locationService = locationService, fhirContext = fhirContext, + jexlEngine = jexlEngine, defaultRepository = defaultRepository, ), ) - resourceDataRulesExecutor = ResourceDataRulesExecutor(rulesFactory) + rulesExecutor = RulesExecutor(rulesFactory) } @Test @@ -100,7 +106,7 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { fun processResourceData() { val patientId = "patient id" val baseResource = Faker.buildPatient(id = patientId) - val relatedRepositoryResourceData = mutableMapOf>() + val relatedRepositoryResourceData = ConcurrentHashMap>() val ruleConfig = RuleConfig( name = "patientName", @@ -110,15 +116,16 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val ruleConfigs = listOf(ruleConfig) runBlocking(Dispatchers.Default) { + val rules = rulesExecutor.rulesFactory.generateRules(ruleConfigs) val resourceData = - resourceDataRulesExecutor.processResourceData( + rulesExecutor.processResourceData( repositoryResourceData = RepositoryResourceData( - resourceRulesEngineFactId = null, + resourceConfigId = null, resource = baseResource, relatedResourcesMap = relatedRepositoryResourceData, ), - ruleConfigs = ruleConfigs, + rules = rules, params = emptyMap(), ) @@ -138,7 +145,7 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val relatedRepositoryResourceData = mutableMapOf>() val computedValuesMap: Map> = emptyMap() val listResourceDataStateMap = mutableStateMapOf>() - resourceDataRulesExecutor.processListResourceData( + rulesExecutor.processListResourceData( listProperties = listProperties, relatedResourcesMap = relatedRepositoryResourceData, computedValuesMap = computedValuesMap, @@ -182,7 +189,7 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { add(anotherPatient) } val listResourceDataStateMap = mutableStateMapOf>() - resourceDataRulesExecutor.processListResourceData( + rulesExecutor.processListResourceData( listProperties = listProperties, relatedResourcesMap = relatedRepositoryResourceData, computedValuesMap = computedValuesMap, @@ -228,7 +235,7 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val listResourceDataStateMap = mutableStateMapOf>() - resourceDataRulesExecutor.processListResourceData( + rulesExecutor.processListResourceData( listProperties = listProperties, relatedResourcesMap = relatedRepositoryResourceData, computedValuesMap = computedValuesMap, @@ -281,7 +288,7 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val listResourceDataStateMap = mutableStateMapOf>() - resourceDataRulesExecutor.processListResourceData( + rulesExecutor.processListResourceData( listProperties = listProperties, relatedResourcesMap = relatedRepositoryResourceData, computedValuesMap = emptyMap(), @@ -368,7 +375,7 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val listResourceDataStateMap = mutableStateMapOf>() - resourceDataRulesExecutor.processListResourceData( + rulesExecutor.processListResourceData( listProperties = listProperties, relatedResourcesMap = relatedRepositoryResourceData, computedValuesMap = emptyMap(), @@ -392,6 +399,6 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { @Test fun getRulesFactory() { - Assert.assertEquals(resourceDataRulesExecutor.rulesFactory, rulesFactory) + Assert.assertEquals(rulesExecutor.rulesFactory, rulesFactory) } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt index 246507880a..2b753d857b 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt @@ -32,9 +32,11 @@ import io.mockk.slot import io.mockk.spyk import io.mockk.verify import java.util.Date +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import org.apache.commons.jexl3.JexlEngine import org.apache.commons.jexl3.JexlException import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding @@ -89,12 +91,14 @@ class RulesFactoryTest : RobolectricTest() { @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var locationService: LocationService + + @Inject lateinit var fhirContext: FhirContext + + @Inject lateinit var jexlEngine: JexlEngine private val rulesEngine = mockk() private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private lateinit var rulesFactory: RulesFactory private lateinit var rulesEngineService: RulesFactory.RulesEngineService - - @Inject lateinit var fhirContext: FhirContext private lateinit var defaultRepository: DefaultRepository @Before @@ -111,6 +115,7 @@ class RulesFactoryTest : RobolectricTest() { dispatcherProvider = dispatcherProvider, locationService = locationService, fhirContext = fhirContext, + jexlEngine = jexlEngine, defaultRepository = defaultRepository, ), ) @@ -133,7 +138,7 @@ class RulesFactoryTest : RobolectricTest() { fun fireRulesCallsRulesEngineFireWithCorrectRulesAndFacts() { runTest { val baseResource = Faker.buildPatient() - val relatedResourcesMap: Map> = emptyMap() + val relatedResourcesMap: ConcurrentHashMap> = ConcurrentHashMap() val ruleConfig = RuleConfig( name = "patientName", @@ -200,37 +205,47 @@ class RulesFactoryTest : RobolectricTest() { secondaryRepositoryResourceData = listOf( RepositoryResourceData( - resourceRulesEngineFactId = "commodities", + resourceConfigId = "commodities", resource = Group().apply { id = "Commodity1" }, relatedResourcesMap = - mapOf( - "stockObservations" to + ConcurrentHashMap>().apply { + put( + "stockObservations", listOf( Observation().apply { id = "Obsv1" }, Observation().apply { id = "Obsv2" }, ), - "latestObservations" to + ) + put( + "latestObservations", listOf( Observation().apply { id = "Obsv3" }, Observation().apply { id = "Obsv4" }, ), - ), + ) + }, relatedResourcesCountMap = - mapOf("stockCount" to listOf(RelatedResourceCount(count = 20))), + ConcurrentHashMap>().apply { + put("stockCount", listOf(RelatedResourceCount(count = 20))) + }, ), RepositoryResourceData( - resourceRulesEngineFactId = "commodities", + resourceConfigId = "commodities", resource = Group().apply { id = "Commodity2" }, relatedResourcesMap = - mapOf( - "stockObservations" to + ConcurrentHashMap>().apply { + put( + "stockObservations", listOf( Observation().apply { id = "Obsv6" }, Observation().apply { id = "Obsv7" }, ), - ), + ) + }, relatedResourcesCountMap = - mapOf("stockCount" to listOf(RelatedResourceCount(count = 10))), + ConcurrentHashMap>().apply { + put("stockCount", listOf(RelatedResourceCount(count = 10))) + }, ), ), ), diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt index fefc5033f4..ac09e2dd4c 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt @@ -1138,7 +1138,7 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { @Test @ExperimentalCoroutinesApi fun `generateOrUpdateCarePlan should generate careplan for 5 visits when lmp has passed 3 months`() = - runTest { + runTest(timeout = 120.seconds) { val monthToDateMap = mutableMapOf>() for (i in 1..12) { diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/report/measure/components/MeasureReportResultScreenTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/report/measure/components/MeasureReportResultScreenTest.kt index 491a776db6..c54fcab8ed 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/report/measure/components/MeasureReportResultScreenTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/report/measure/components/MeasureReportResultScreenTest.kt @@ -33,7 +33,7 @@ import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.register.RegisterRepository -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.quest.data.report.measure.MeasureReportRepository import org.smartregister.fhircore.quest.integration.Faker @@ -70,7 +70,7 @@ class MeasureReportResultScreenTest { @Inject lateinit var defaultRepository: DefaultRepository - @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var rulesExecutor: RulesExecutor @Inject lateinit var measureReportRepository: MeasureReportRepository @@ -87,7 +87,7 @@ class MeasureReportResultScreenTest { registerRepository, measureReportSubjectViewDataMapper, defaultRepository, - resourceDataRulesExecutor, + rulesExecutor, measureReportRepository, ) } diff --git a/android/quest/src/main/AndroidManifest.xml b/android/quest/src/main/AndroidManifest.xml index d403ae5834..67b25fb1e1 100644 --- a/android/quest/src/main/AndroidManifest.xml +++ b/android/quest/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ tools:replace="android:allowBackup,android:theme"> val resource = repositoryResourceData.resource val jsonParse = JsonPath.using(conf).parse(resource.encodeResourceToString()) - + val rules = rulesExecutor.rulesFactory.generateRules(migrationConfig.rules) val updatedResourceDocument = jsonParse.apply { migrationConfig.updateValues.forEach { updateExpression -> // Expression stars with '$' (JSONPath) or ResourceType like in FHIRPath val value = computeValueRule( - rules = migrationConfig.rules, + rules = rules, repositoryResourceData = repositoryResourceData, computedValueKey = updateExpression.computedValueKey, ) @@ -234,13 +237,13 @@ constructor( } private fun computeValueRule( - rules: List?, + rules: Rules, repositoryResourceData: RepositoryResourceData, computedValueKey: String, ): Any? { - return resourceDataRulesExecutor + return rulesExecutor .computeResourceDataRules( - ruleConfigs = rules ?: emptyList(), + rules = rules, repositoryResourceData = repositoryResourceData, params = emptyMap(), )[computedValueKey] diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/geowidget/GeoWidgetPagingSource.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/geowidget/GeoWidgetPagingSource.kt deleted file mode 100644 index 8e0217064b..0000000000 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/geowidget/GeoWidgetPagingSource.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * 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.quest.data.geowidget - -import android.database.SQLException -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.google.android.fhir.datacapture.extensions.logicalId -import kotlinx.serialization.json.JsonPrimitive -import org.hl7.fhir.r4.model.Location -import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration -import org.smartregister.fhircore.engine.data.local.DefaultRepository -import org.smartregister.fhircore.engine.data.local.register.RegisterRepository -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor -import org.smartregister.fhircore.engine.util.extension.interpolate -import org.smartregister.fhircore.geowidget.model.GeoJsonFeature -import org.smartregister.fhircore.geowidget.model.Geometry -import timber.log.Timber - -/** [RegisterRepository] function for loading data to the paging source. */ -class GeoWidgetPagingSource( - private val defaultRepository: DefaultRepository, - private val resourceDataRulesExecutor: ResourceDataRulesExecutor, - private val geoWidgetConfig: GeoWidgetConfiguration, -) : PagingSource() { - - override suspend fun load(params: LoadParams): LoadResult { - return try { - val currentPage = params.key ?: 0 - val prevKey = if (currentPage > 0) currentPage - 1 else null - - val registerData = - defaultRepository.searchResourcesRecursively( - filterActiveResources = null, - fhirResourceConfig = geoWidgetConfig.resourceConfig, - configRules = null, - secondaryResourceConfigs = null, - filterByRelatedEntityLocationMetaTag = - geoWidgetConfig.filterDataByRelatedEntityLocation == true, - currentPage = currentPage, - pageSize = DEFAULT_PAGE_SIZE, - ) - - val nextKey = if (registerData.isNotEmpty()) currentPage + 1 else null - - val data = - registerData - .asSequence() - .filter { it.resource is Location } - .filter { (it.resource as Location).hasPosition() } - .filter { with((it.resource as Location).position) { hasLongitude() && hasLatitude() } } - .map { - Pair( - it.resource as Location, - resourceDataRulesExecutor.processResourceData( - repositoryResourceData = it, - ruleConfigs = geoWidgetConfig.servicePointConfig?.rules ?: emptyList(), - params = emptyMap(), - ), - ) - } - .map { (location, resourceData) -> - GeoJsonFeature( - id = location.logicalId, - geometry = - Geometry( - coordinates = // MapBox coordinates are represented as Long,Lat (NOT Lat,Long) - listOf( - location.position.longitude.toDouble(), - location.position.latitude.toDouble(), - ), - ), - properties = - geoWidgetConfig.servicePointConfig?.servicePointProperties?.mapValues { - JsonPrimitive(it.value.interpolate(resourceData.computedValuesMap)) - } ?: emptyMap(), - ) - } - .toList() - LoadResult.Page(data = data, prevKey = prevKey, nextKey = nextKey) - } catch (exception: SQLException) { - Timber.e(exception) - LoadResult.Error(exception) - } catch (exception: Exception) { - Timber.e(exception) - LoadResult.Error(exception) - } - } - - override fun getRefreshKey(state: PagingState): Int? { - return state.anchorPosition?.let { anchorPosition -> - state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) - ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) - } - } - - companion object { - const val DEFAULT_PAGE_SIZE = 20 - } -} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt index f489eba575..0d5377fdb3 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt @@ -22,25 +22,22 @@ import androidx.paging.PagingState import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceData -import org.smartregister.fhircore.engine.domain.model.RuleConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.quest.data.register.model.RegisterPagingSourceState import timber.log.Timber /** - * @property _registerPagingSourceState as state containing the properties used in the + * @property registerPagingSourceState as state containing the properties used in the * [RegisterRepository] function for loading data to the paging source. */ class RegisterPagingSource( private val registerRepository: RegisterRepository, - private val resourceDataRulesExecutor: ResourceDataRulesExecutor, - private val ruleConfigs: List, private val fhirResourceConfig: FhirResourceConfig?, private val actionParameters: Map?, + private val registerPagingSourceState: RegisterPagingSourceState, + private val rulesExecutor: RulesExecutor, ) : PagingSource() { - private lateinit var _registerPagingSourceState: RegisterPagingSourceState - /** * To load data for the current page, nextKey and prevKey for [params] are both set to null to * prevent automatic loading of by the [PagingSource]. This is done in order to explicitly allow @@ -55,32 +52,32 @@ class RegisterPagingSource( */ override suspend fun load(params: LoadParams): LoadResult { return try { - val currentPage = params.key ?: _registerPagingSourceState.currentPage + val currentPage = params.key ?: registerPagingSourceState.currentPage val registerData = - registerRepository.loadRegisterData( - currentPage = currentPage, - registerId = _registerPagingSourceState.registerId, - fhirResourceConfig = fhirResourceConfig, - ) + registerRepository + .loadRegisterData( + currentPage = currentPage, + registerId = registerPagingSourceState.registerId, + fhirResourceConfig = fhirResourceConfig, + paramsMap = actionParameters, + ) + .map { + rulesExecutor.processResourceData( + repositoryResourceData = it, + params = actionParameters, + rules = registerPagingSourceState.rules, + ) + } val prevKey = - if (_registerPagingSourceState.loadAll && currentPage > 0) currentPage - 1 else null + if (registerPagingSourceState.loadAll && currentPage > 0) currentPage - 1 else null val nextKey = - if (_registerPagingSourceState.loadAll && registerData.isNotEmpty()) { + if (registerPagingSourceState.loadAll && registerData.isNotEmpty()) { currentPage + 1 } else { null } - - val data = - registerData.map { repositoryResourceData -> - resourceDataRulesExecutor.processResourceData( - repositoryResourceData = repositoryResourceData, - ruleConfigs = ruleConfigs, - params = actionParameters, - ) - } - LoadResult.Page(data = data, prevKey = prevKey, nextKey = nextKey) + LoadResult.Page(data = registerData, prevKey = prevKey, nextKey = nextKey) } catch (exception: SQLException) { Timber.e(exception) LoadResult.Error(exception) @@ -90,11 +87,6 @@ class RegisterPagingSource( } } - @Synchronized - fun setPatientPagingSourceState(registerPagingSourceState: RegisterPagingSourceState) { - this._registerPagingSourceState = registerPagingSourceState - } - override fun getRefreshKey(state: PagingState): Int? { return state.anchorPosition?.let { anchorPosition -> state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/model/RegisterPagingSourceState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/model/RegisterPagingSourceState.kt index 45618e0b9c..ff1f8eb242 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/model/RegisterPagingSourceState.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/model/RegisterPagingSourceState.kt @@ -16,8 +16,11 @@ package org.smartregister.fhircore.quest.data.register.model +import org.jeasy.rules.api.Rules + data class RegisterPagingSourceState( val registerId: String, val currentPage: Int = 0, val loadAll: Boolean = false, + val rules: Rules, ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSource.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSource.kt index 3ba58f4d42..8fff07f17f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSource.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSource.kt @@ -26,13 +26,13 @@ import org.smartregister.fhircore.engine.configuration.report.measure.ReportConf import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceData -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor class MeasureReportPagingSource( private val measureReportConfiguration: MeasureReportConfiguration, private val registerConfiguration: RegisterConfiguration, private val registerRepository: RegisterRepository, - private val resourceDataRulesExecutor: ResourceDataRulesExecutor, + private val rulesExecutor: RulesExecutor, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { @@ -51,14 +51,15 @@ class MeasureReportPagingSource( val xFhirQuery = measureReportConfiguration.reports.firstOrNull()?.subjectXFhirQuery ?: ResourceType.Patient.name + val rules = rulesExecutor.rulesFactory.generateRules(registerConfiguration.registerCard.rules) return registerRepository.fhirEngine.search(xFhirQuery).map { - resourceDataRulesExecutor.processResourceData( + rulesExecutor.processResourceData( repositoryResourceData = RepositoryResourceData( - resourceRulesEngineFactId = it.resource.resourceType.name, + resourceConfigId = it.resource.resourceType.name, resource = it.resource, ), - ruleConfigs = registerConfiguration.registerCard.rules, + rules = rules, params = emptyMap(), ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepository.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepository.kt index 569fbd4278..dde99f73bb 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepository.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepository.kt @@ -120,7 +120,7 @@ constructor( } measureReport.forEach { report -> - // if report exists override instead of creating a new one + // if report exists instead of creating a new one existing .find { it.measure == report.measure && @@ -196,7 +196,7 @@ constructor( val resource = searchResult.resource if (resource is Group && !resource.hasMember()) { resource.addMember(Group.GroupMemberComponent(resource.asReference())) - update(resource) + addOrUpdate(resource = resource) } "${resource.resourceType.name}/${resource.logicalId}" } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingActivity.kt index 985bbc3c50..091114319f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingActivity.kt @@ -14,18 +14,14 @@ * limitations under the License. */ -@file:OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) - package org.smartregister.fhircore.quest.ui.appsetting import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.res.stringResource import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt index f6f2ff9eb4..fe8b6789bd 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt @@ -69,6 +69,7 @@ constructor( ) : ViewModel() { private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK + private val exceptionHandler = CoroutineExceptionHandler { _, exception -> Timber.e(exception) } val showProgressBar = MutableLiveData(false) @@ -101,8 +102,6 @@ constructor( } } - private val exceptionHandler = CoroutineExceptionHandler { _, exception -> Timber.e(exception) } - private fun fetchRemoteConfigurations(appId: String?, context: Context) { viewModelScope.launch(exceptionHandler) { try { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt index 5c9eef34ff..8f7ae4b20e 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt @@ -32,23 +32,20 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonPrimitive import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Location -import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration -import org.smartregister.fhircore.engine.configuration.register.ActiveResourceFilterConfig 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.MultiSelectViewAction import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @@ -68,7 +65,7 @@ constructor( val defaultRepository: DefaultRepository, val dispatcherProvider: DispatcherProvider, val sharedPreferencesHelper: SharedPreferencesHelper, - val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val rulesExecutor: RulesExecutor, val configurationRegistry: ConfigurationRegistry, @ApplicationContext val context: Context, ) : ViewModel() { @@ -101,119 +98,94 @@ constructor( searchText: String?, ) { viewModelScope.launch { - val totalCount = - withContext(dispatcherProvider.io()) { - defaultRepository.countResources( - filterByRelatedEntityLocation = - geoWidgetConfig.filterDataByRelatedEntityLocation == true, - baseResourceConfig = geoWidgetConfig.resourceConfig.baseResource, - filterActiveResources = - listOf( - ActiveResourceFilterConfig( - resourceType = ResourceType.Patient, - active = true, - ), - ActiveResourceFilterConfig( - resourceType = ResourceType.Group, - active = true, - ), - ), + val (locationsWithCoordinates, locationsWithoutCoordinates) = + defaultRepository + .searchNestedResources( + baseResourceIds = null, + fhirResourceConfig = geoWidgetConfig.resourceConfig, configComputedRuleValues = emptyMap(), + activeResourceFilters = null, + filterByRelatedEntityLocationMetaTag = + geoWidgetConfig.filterDataByRelatedEntityLocation == true, + currentPage = null, + pageSize = null, ) - } - if (totalCount == 0L) { - showNoLocationDialog(geoWidgetConfig) - return@launch - } - var count = 0 - var pageNumber = 0 - var locationsWithoutCoordinatesCount = 0L - var registerDataCount = 0L - while (count < totalCount) { - val (locationsWithCoordinates, locationsWithoutCoordinates) = - defaultRepository - .searchResourcesRecursively( - filterActiveResources = null, - fhirResourceConfig = geoWidgetConfig.resourceConfig, - configRules = null, - secondaryResourceConfigs = null, - filterByRelatedEntityLocationMetaTag = - geoWidgetConfig.filterDataByRelatedEntityLocation == true, - currentPage = pageNumber, - pageSize = DefaultRepository.DEFAULT_BATCH_SIZE, - ) - .asSequence() - .filter { it.resource is Location } - .partition { - with((it.resource as Location).position) { hasLongitude() && hasLatitude() } - } + .values + .asSequence() + .filter { it.resource is Location } + .partition { + with((it.resource as Location).position) { hasLongitude() && hasLatitude() } + } + + val rules = + rulesExecutor.rulesFactory.generateRules( + geoWidgetConfig.servicePointConfig?.rules ?: emptyList(), + ) - val registerData = - locationsWithCoordinates - .asSequence() - .map { - Pair( - it.resource as Location, - resourceDataRulesExecutor.processResourceData( - repositoryResourceData = it, - ruleConfigs = geoWidgetConfig.servicePointConfig?.rules ?: emptyList(), - params = emptyMap(), + val registerData = + locationsWithCoordinates + .asSequence() + .map { + Pair( + it.resource as Location, + rulesExecutor.processResourceData( + repositoryResourceData = it, + rules = rules, + params = emptyMap(), + ), + ) + } + .map { (location, resourceData) -> + GeoJsonFeature( + id = location.logicalId, + geometry = + Geometry( + coordinates = // MapBox coordinates are represented as Long,Lat (NOT Lat,Long) + listOf( + location.position.longitude.toDouble(), + location.position.latitude.toDouble(), + ), ), - ) - } - .map { (location, resourceData) -> - GeoJsonFeature( - id = location.logicalId, - geometry = - Geometry( - coordinates = // MapBox coordinates are represented as Long,Lat (NOT Lat,Long) - listOf( - location.position.longitude.toDouble(), - location.position.latitude.toDouble(), - ), - ), - properties = - geoWidgetConfig.servicePointConfig?.servicePointProperties?.mapValues { - JsonPrimitive(it.value.interpolate(resourceData.computedValuesMap)) - } ?: emptyMap(), - ) - } - .toList() - val features = - if (searchText.isNullOrBlank()) { - registerData - } else { - registerData.filter { geoJsonFeature: GeoJsonFeature -> - geoWidgetConfig.topScreenSection?.searchBar?.computedRules?.any { ruleName -> - // if ruleName not found in map return {-1}; check always return false hence no data - val value = geoJsonFeature.properties[ruleName]?.toString() ?: "{-1}" - value.contains(other = searchText, ignoreCase = true) - } == true - } + properties = + geoWidgetConfig.servicePointConfig?.servicePointProperties?.mapValues { + JsonPrimitive(it.value.interpolate(resourceData.computedValuesMap)) + } ?: emptyMap(), + ) + } + .toList() + val features = + if (searchText.isNullOrBlank()) { + registerData + } else { + registerData.filter { geoJsonFeature: GeoJsonFeature -> + geoWidgetConfig.topScreenSection?.searchBar?.computedRules?.any { ruleName -> + // if ruleName not found in map return {-1}; check always return false hence no data + val value = geoJsonFeature.properties[ruleName]?.toString() ?: "{-1}" + value.contains(other = searchText, ignoreCase = true) + } == true } + } - geoJsonFeatures.postValue(features) + geoJsonFeatures.postValue(features) - Timber.w( - locationsWithoutCoordinates.joinToString("\n") { - val position = (it.resource as Location).position - "Location id ${it.resource.logicalId} coordinates (${position.longitude},${position.latitude}) invalid." - }, - ) - pageNumber++ - count += DefaultRepository.DEFAULT_BATCH_SIZE - registerDataCount += features.size - locationsWithoutCoordinatesCount += locationsWithoutCoordinates.size - } + Timber.w( + locationsWithoutCoordinates.joinToString("\n") { + val position = (it.resource as Location).position + "Location id ${it.resource.logicalId} coordinates (${position.longitude},${position.latitude}) invalid." + }, + ) - val locationsCount = if (searchText.isNullOrBlank()) totalCount else registerDataCount + val locationsCount = + if (searchText.isNullOrBlank()) { + locationsWithCoordinates.size + locationsWithoutCoordinates.size + } else features.size // Account for locations without coordinates - if (locationsWithoutCoordinatesCount in 1..locationsCount) { + if (locationsWithoutCoordinates.size in 1..locationsCount) { val message = context.getString( R.string.locations_without_coordinates, - locationsWithoutCoordinatesCount, + locationsWithoutCoordinates.size, locationsCount, ) Timber.w(message) @@ -228,7 +200,9 @@ constructor( val message = if (searchText.isNullOrBlank()) { context.getString(R.string.all_locations_rendered) - } else context.getString(R.string.all_matching_locations_rendered, locationsCount) + } else { + context.getString(R.string.all_matching_locations_rendered, locationsCount) + } emitSnackBarState( SnackBarMessageConfig( message = message, @@ -239,7 +213,7 @@ constructor( } // Account for missing locations - if (locationsCount == 0L) { + if (locationsCount == 0) { if (!searchText.isNullOrBlank()) { val message = context.getString( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index 3a239198b1..6f446cc57a 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -328,7 +328,9 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, listener = { dialog -> dialog.dismiss() }, ), ) - } else navHostFragment.navController.navigateUp() + } else { + navHostFragment.navController.navigateUp() + } } }, ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt index c7e7bb1b3b..479f0c9c2d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt @@ -71,17 +71,21 @@ constructor( } } - val repositoryResourceData = - defaultRepository.searchResourcesRecursively( - filterByRelatedEntityLocationMetaTag = false, - fhirResourceConfig = multiSelectViewConfig.resourceConfig, - filterActiveResources = null, - secondaryResourceConfigs = null, - configRules = null, - ) + val repositoryResourceDataList = + defaultRepository + .searchNestedResources( + baseResourceIds = null, + fhirResourceConfig = multiSelectViewConfig.resourceConfig, + configComputedRuleValues = emptyMap(), + activeResourceFilters = null, + filterByRelatedEntityLocationMetaTag = false, + currentPage = null, + pageSize = null, + ) + .values val resourcesMap = - repositoryResourceData.associateByTo( + repositoryResourceDataList.associateByTo( mutableMapOf(), { it.resource.logicalId }, { it.resource }, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt index f627153a43..2575610088 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt @@ -61,17 +61,25 @@ class ProfileFragment : Fragment() { savedInstanceState: Bundle?, ): View { with(profileFragmentArgs) { - lifecycleScope.launch { - profileViewModel.run { - retrieveProfileUiState(profileId, resourceId, resourceConfig, params) - } - } + profileViewModel.retrieveProfileUiState( + requireContext(), + profileId, + resourceId, + resourceConfig, + params, + ) } profileViewModel.refreshProfileDataLiveData.observe(viewLifecycleOwner) { if (it == true) { with(profileFragmentArgs) { - profileViewModel.retrieveProfileUiState(profileId, resourceId, resourceConfig, params) + profileViewModel.retrieveProfileUiState( + requireContext(), + profileId, + resourceId, + resourceConfig, + params, + ) } profileViewModel.refreshProfileDataLiveData.value = null } @@ -116,7 +124,13 @@ class ProfileFragment : Fragment() { appMainViewModel.onQuestionnaireSubmission(this) with(profileFragmentArgs) { - profileViewModel.retrieveProfileUiState(profileId, resourceId, resourceConfig, params) + profileViewModel.retrieveProfileUiState( + requireContext(), + profileId, + resourceId, + resourceConfig, + params, + ) } questionnaireConfig.snackBarMessage?.let { snackBarMessageConfig -> diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModel.kt index ceec5ca81d..761c2ac3f2 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModel.kt @@ -16,10 +16,12 @@ package org.smartregister.fhircore.quest.ui.profile +import android.content.Context import android.graphics.Bitmap import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -47,7 +49,7 @@ import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid @@ -69,7 +71,7 @@ constructor( val configurationRegistry: ConfigurationRegistry, val dispatcherProvider: DispatcherProvider, val fhirPathDataExtractor: FhirPathDataExtractor, - val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val rulesExecutor: RulesExecutor, ) : ViewModel() { val refreshProfileDataLiveData = MutableLiveData(null) @@ -80,49 +82,68 @@ constructor( private val _snackBarStateFlow = MutableSharedFlow() val snackBarStateFlow: SharedFlow = _snackBarStateFlow.asSharedFlow() private lateinit var profileConfiguration: ProfileConfiguration - - private val listResourceDataStateMap = - mutableStateMapOf>() - private val decodedImageMap = mutableStateMapOf() + private val listResourceDataMap = SnapshotStateMap>() fun retrieveProfileUiState( + context: Context, profileId: String, resourceId: String, fhirResourceConfig: FhirResourceConfig? = null, paramsList: Array? = emptyArray(), ) { viewModelScope.launch { - if (resourceId.isNotEmpty()) { - val repositoryResourceData = - registerRepository.loadProfileData(profileId, resourceId, fhirResourceConfig, paramsList) - val paramsMap: Map = paramsList.toParamDataMap() - val profileConfigs = retrieveProfileConfiguration(profileId, paramsMap) - val resourceData = - resourceDataRulesExecutor - .processResourceData( - repositoryResourceData = repositoryResourceData, - ruleConfigs = profileConfigs.rules, - params = paramsMap, - ) - .copy(listResourceDataMap = listResourceDataStateMap) + if (resourceId.isNotBlank()) { + kotlin + .runCatching { + val paramsMap = paramsList.toParamDataMap() + val profileConfiguration = retrieveProfileConfiguration(profileId, paramsMap) + val repositoryResourceData = + registerRepository.loadProfileData( + profileId = profileId, + resourceId = resourceId, + fhirResourceConfig = fhirResourceConfig, + paramsMap = paramsMap, + ) ?: throw IllegalStateException("Unable to render profile") - profileUiState.value = - ProfileUiState( - resourceData = resourceData, - profileConfiguration = profileConfigs, - snackBarTheme = applicationConfiguration.snackBarTheme, - showDataLoadProgressIndicator = false, - ) + val rules = rulesExecutor.rulesFactory.generateRules(profileConfiguration.rules) + val resourceData = + rulesExecutor + .processResourceData( + repositoryResourceData = repositoryResourceData, + params = paramsMap, + rules = rules, + ) + .copy(listResourceDataMap = listResourceDataMap) - profileConfigs.views.retrieveListProperties().forEach { listProperties -> - resourceDataRulesExecutor.processListResourceData( - listProperties = listProperties, - relatedResourcesMap = repositoryResourceData.relatedResourcesMap, - computedValuesMap = resourceData.computedValuesMap.plus(paramsMap), - listResourceDataStateMap = listResourceDataStateMap, - ) - } + profileUiState.value = + ProfileUiState( + resourceData = resourceData, + profileConfiguration = profileConfiguration, + snackBarTheme = applicationConfiguration.snackBarTheme, + showDataLoadProgressIndicator = false, + ) + + profileConfiguration.views.retrieveListProperties().forEach { listProperties -> + rulesExecutor.processListResourceData( + listProperties = listProperties, + relatedResourcesMap = repositoryResourceData.relatedResourcesMap, + computedValuesMap = + if (paramsMap.isNotEmpty()) { + resourceData.computedValuesMap.plus( + paramsMap.toList(), + ) + } else resourceData.computedValuesMap, + listResourceDataStateMap = listResourceDataMap, + ) + } + } + .onFailure { + Timber.e("Unable to render profile") + _snackBarStateFlow.emit( + SnackBarMessageConfig(context.getString(R.string.error_rendering_profile)), + ) + } } } } @@ -131,7 +152,6 @@ constructor( profileId: String, paramsMap: Map?, ): ProfileConfiguration { - // Ensures profile configuration is initialized once if (!::profileConfiguration.isInitialized) { profileConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Profile, profileId, paramsMap) @@ -147,26 +167,24 @@ constructor( when (event) { is ProfileEvent.OverflowMenuClick -> { val actions = event.overflowMenuItemConfig?.actions - viewModelScope.launch { - actions?.run { - find { actionConfig -> - actionConfig - .interpolate(event.resourceData?.computedValuesMap ?: emptyMap()) - .workflow - ?.let { workflow -> ApplicationWorkflow.valueOf(workflow) } == - ApplicationWorkflow.CHANGE_MANAGING_ENTITY - } - ?.let { - changeManagingEntity( - event = event, - managingEntity = - it - .interpolate(event.resourceData?.computedValuesMap ?: emptyMap()) - .managingEntity, - ) - } - handleClickEvent(navController = event.navController, resourceData = event.resourceData) - } + actions?.run { + find { actionConfig -> + actionConfig + .interpolate(event.resourceData?.computedValuesMap ?: emptyMap()) + .workflow + ?.let { workflow -> ApplicationWorkflow.valueOf(workflow) } == + ApplicationWorkflow.CHANGE_MANAGING_ENTITY + } + ?.let { + changeManagingEntity( + event = event, + managingEntity = + it + .interpolate(event.resourceData?.computedValuesMap ?: emptyMap()) + .managingEntity, + ) + } + handleClickEvent(navController = event.navController, resourceData = event.resourceData) } } is ProfileEvent.OnChangeManagingEntity -> { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt index b6f8592318..ecbe982df5 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt @@ -73,7 +73,7 @@ import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType import org.smartregister.fhircore.engine.domain.model.isEditable import org.smartregister.fhircore.engine.domain.model.isReadOnly -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.task.FhirCarePlanGenerator import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey @@ -110,7 +110,7 @@ constructor( val defaultRepository: DefaultRepository, val dispatcherProvider: DispatcherProvider, val fhirCarePlanGenerator: FhirCarePlanGenerator, - val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val rulesExecutor: RulesExecutor, val transformSupportServices: TransformSupportServices, val sharedPreferencesHelper: SharedPreferencesHelper, val fhirOperator: FhirOperator, @@ -900,7 +900,9 @@ constructor( private fun getStringRepresentation(base: Base): String = if (base.isResource) { FhirContext.forR4Cached().newJsonParser().encodeResourceToString(base as Resource) - } else base.toString() + } else { + base.toString() + } /** * This function generates CarePlans for the [QuestionnaireResponse.subject] using the configured @@ -1108,7 +1110,10 @@ constructor( questionnaire.prepopulateWithComputedConfigValues( questionnaireConfig, actionParameters, - { resourceDataRulesExecutor.computeResourceDataRules(it, null, emptyMap()) }, + { + val rules = rulesExecutor.rulesFactory.generateRules(it) + rulesExecutor.computeResourceDataRules(rules, null, emptyMap()) + }, { uniqueIdAssignmentConfig, computedValues -> // Extract ID from a Group, should be modified in future to support other resources uniqueIdResource = diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt index ec82084b82..8276382aae 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt @@ -76,7 +76,7 @@ import org.smartregister.fhircore.engine.domain.model.NestedSearchConfig import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @@ -96,51 +96,49 @@ constructor( val registerRepository: RegisterRepository, val configurationRegistry: ConfigurationRegistry, val sharedPreferencesHelper: SharedPreferencesHelper, - val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val rulesExecutor: RulesExecutor, val dispatcherProvider: DispatcherProvider, ) : ViewModel() { + private lateinit var registerConfiguration: RegisterConfiguration + private val _snackBarStateFlow = MutableSharedFlow() val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() + val registerUiState = mutableStateOf(RegisterUiState()) val registerUiCountState = mutableStateOf(RegisterUiCountState()) val currentPage: MutableState = mutableIntStateOf(0) val registerData: MutableStateFlow>> = MutableStateFlow(emptyFlow()) val pagesDataCache = mutableMapOf>>() val registerFilterState = mutableStateOf(RegisterFilterState()) - private val _totalRecordsCount = mutableLongStateOf(0L) - private val _filteredRecordsCount = mutableLongStateOf(-1L) - private lateinit var registerConfiguration: RegisterConfiguration - private var completeRegisterData: Flow>? = null + val applicationConfiguration: ApplicationConfiguration by lazy { + configurationRegistry.retrieveConfiguration(ConfigType.Application, paramsMap = emptyMap()) + } + val searchQueryFlow: MutableSharedFlow = MutableSharedFlow() private val _percentageProgress: MutableSharedFlow = MutableSharedFlow(0) private val _isUploadSync: MutableSharedFlow = MutableSharedFlow(0) private val _currentSyncJobStatusFlow: MutableSharedFlow = MutableSharedFlow(0) - val applicationConfiguration: ApplicationConfiguration by lazy { - configurationRegistry.retrieveConfiguration(ConfigType.Application, paramsMap = emptyMap()) - } private val decodedImageMap = mutableStateMapOf() - - private val _searchQueryFlow: MutableSharedFlow = MutableSharedFlow() - - @VisibleForTesting - val debouncedSearchQueryFlow = - _searchQueryFlow.debounce { - val searchText = it.query - when (searchText.length) { - 0 -> 2.milliseconds // when search is cleared - 1, - 2, -> 1000.milliseconds - else -> 500.milliseconds - } - } + private val _totalRecordsCount = mutableLongStateOf(0L) + private val _filteredRecordsCount = mutableLongStateOf(-1L) init { viewModelScope.launch { - debouncedSearchQueryFlow.collect { - val registerId = registerUiState.value.registerId - performSearch(registerId, it) - } + searchQueryFlow + .debounce { + val searchText = it.query + when (searchText.length) { + 0 -> 2.milliseconds // when search is cleared + 1, + 2, -> 1000.milliseconds + else -> 500.milliseconds + } + } + .collect { + val registerId = registerUiState.value.registerId + performSearch(registerId, it) + } } } @@ -154,42 +152,39 @@ constructor( loadAll: Boolean = false, clearCache: Boolean = false, ) { - if (clearCache) { - pagesDataCache.clear() - completeRegisterData = null - } + // TODO Replace Cache with LRU Cache + if (clearCache) pagesDataCache.clear() registerData.value = - pagesDataCache.getOrPut(currentPage.value) { - getPager(registerId, loadAll).flow.cachedIn(viewModelScope) - } + pagesDataCache.getOrPut(currentPage.value) { getPagerFlow(registerId, loadAll) } } - private fun getPager(registerId: String, loadAll: Boolean = false): Pager { - val currentRegisterConfigs = retrieveRegisterConfiguration(registerId) - val ruleConfigs = currentRegisterConfigs.registerCard.rules - val pageSize = currentRegisterConfigs.pageSize - + private fun getPagerFlow( + registerId: String, + loadAll: Boolean = false, + ): Flow> { + val currentRegisterConfig = retrieveRegisterConfiguration(registerId) + val pageSize = currentRegisterConfig.pageSize + val rules = rulesExecutor.rulesFactory.generateRules(currentRegisterConfig.registerCard.rules) return Pager( - config = PagingConfig(pageSize = pageSize, enablePlaceholders = false), - pagingSourceFactory = { - RegisterPagingSource( + config = PagingConfig(pageSize = pageSize, prefetchDistance = pageSize / 2), + pagingSourceFactory = { + RegisterPagingSource( registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, - ruleConfigs = ruleConfigs, fhirResourceConfig = registerFilterState.value.fhirResourceConfig, actionParameters = registerUiState.value.params.toTypedArray().toParamDataMap(), - ) - .apply { - setPatientPagingSourceState( + registerPagingSourceState = RegisterPagingSourceState( - registerId = registerId, + registerId = currentRegisterConfig.id, loadAll = loadAll, currentPage = if (loadAll) 0 else currentPage.value, + rules = rules, ), - ) - } - }, - ) + rulesExecutor = rulesExecutor, + ) + }, + ) + .flow + .cachedIn(viewModelScope) } fun retrieveRegisterConfiguration( @@ -208,22 +203,12 @@ constructor( return registerConfiguration } - private fun retrieveCompleteRegisterData( - registerId: String, - forceRefresh: Boolean, - ): Flow> { - if (completeRegisterData == null || forceRefresh) { - completeRegisterData = getPager(registerId, true).flow.cachedIn(viewModelScope) - } - return completeRegisterData!! - } - fun onEvent(event: RegisterEvent) { val registerId = registerUiState.value.registerId when (event) { // Search using name or patient logicalId or identifier. Modify to add more search params is RegisterEvent.SearchRegister -> { - viewModelScope.launch { _searchQueryFlow.emit(event.searchQuery) } + viewModelScope.launch { searchQueryFlow.emit(event.searchQuery) } } is RegisterEvent.MoveToNextPage -> { currentPage.value = currentPage.value.plus(1) @@ -247,7 +232,7 @@ constructor( } when { regConfig.infiniteScroll -> - registerData.value = retrieveCompleteRegisterData(registerId, searchByDynamicQueries) + registerData.value = getPagerFlow(registerId, searchByDynamicQueries) else -> retrieveRegisterUiState( registerId = registerId, @@ -278,20 +263,15 @@ constructor( paginateRegisterData(registerId = registerId, loadAll = true, clearCache = true) } else if (searchBar?.computedRules != null) { registerData.value = - retrieveCompleteRegisterData( - registerId = registerId, - forceRefresh = false, - ) - .map { pagingData: PagingData, - -> - pagingData.filter { resourceData: ResourceData -> - searchBar.computedRules!!.any { ruleName -> - // if ruleName not found in map return {-1}; check always return false hence no data - val value = resourceData.computedValuesMap[ruleName]?.toString() ?: "{-1}" - value.contains(other = searchText, ignoreCase = true) - } + getPagerFlow(registerId, true).map { pagingData: PagingData -> + pagingData.filter { resourceData: ResourceData -> + searchBar.computedRules!!.any { ruleName -> + // if ruleName not found in map return {-1}; check always return false hence no data + val value = resourceData.computedValuesMap[ruleName]?.toString() ?: "{-1}" + value.contains(other = searchText, ignoreCase = true) } } + } } } @@ -659,18 +639,16 @@ constructor( ) { if (registerId.isNotEmpty()) { val paramsMap: Map = params.toParamDataMap() - val currentRegisterConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) if (currentRegisterConfiguration.infiniteScroll) { - registerData.value = - retrieveCompleteRegisterData(currentRegisterConfiguration.id, clearCache) + registerData.value = getPagerFlow(currentRegisterConfiguration.id, clearCache) } else { paginateRegisterData( registerId = registerId, loadAll = false, clearCache = clearCache, ) - viewModelScope.launch(dispatcherProvider.io()) { + viewModelScope.launch { _totalRecordsCount.longValue = registerRepository.countRegisterData( registerId = registerId, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt index 302bfcd1a3..c1da1c8c2d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt @@ -53,7 +53,7 @@ import org.smartregister.fhircore.engine.configuration.report.measure.MeasureRep import org.smartregister.fhircore.engine.configuration.report.measure.ReportConfiguration import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.register.RegisterRepository -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.SDF_D_MMM_YYYY_WITH_COMA @@ -101,7 +101,7 @@ constructor( val registerRepository: RegisterRepository, val measureReportSubjectViewDataMapper: MeasureReportSubjectViewDataMapper, val defaultRepository: DefaultRepository, - val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val rulesExecutor: RulesExecutor, private val measureReportRepository: MeasureReportRepository, ) : ViewModel() { private val _measureReportPopulationResultList: MutableList = @@ -139,7 +139,7 @@ constructor( measureReportConfiguration = measureReportConfiguration, registerConfiguration = registerConfiguration, registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, ) } .flow @@ -253,7 +253,7 @@ constructor( measureReportConfiguration = measureReportConfig, registerConfiguration = registerConfiguration, registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, ), measureReportSubjectViewDataMapper, ) diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 113c8e6d31..9c96b97c3d 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -141,4 +141,5 @@ All the locations rendered successfully" %1$d matching location(s) rendered successfully" Cancel adding location + Error rendering profile diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/DataMigrationTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/DataMigrationTest.kt index 7586d323b6..c15918202d 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/DataMigrationTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/DataMigrationTest.kt @@ -71,7 +71,7 @@ class DataMigrationTest : RobolectricTest() { @Test fun testMigrateShouldUpdateResources() = - runTest(timeout = 45.seconds) { + runTest(timeout = 60.seconds) { // Create patient to be updated defaultRepository.create(addResourceTags = true, patient) dataMigration.migrate( diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt index 459390d854..87c7464e37 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt @@ -22,10 +22,13 @@ import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.Task +import org.jeasy.rules.api.Rules import org.junit.Assert import org.junit.Before import org.junit.Rule @@ -34,7 +37,7 @@ import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.data.register.model.RegisterPagingSourceState @@ -45,7 +48,7 @@ class RegisterPagingSourceTest : RobolectricTest() { @get:Rule(order = 0) val hiltAndroidRule = HiltAndroidRule(this) - @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var rulesExecutor: RulesExecutor private val registerRepository = mockk() private lateinit var registerPagingSource: RegisterPagingSource private val registerId = "registerId" @@ -60,10 +63,16 @@ class RegisterPagingSourceTest : RobolectricTest() { registerPagingSource = RegisterPagingSource( registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, - ruleConfigs = listOf(), - actionParameters = emptyMap(), fhirResourceConfig = null, + actionParameters = emptyMap(), + rulesExecutor = rulesExecutor, + registerPagingSourceState = + RegisterPagingSourceState( + registerId = registerId, + currentPage = 0, + loadAll = false, + rules = Rules(setOf()), + ), ) coEvery { registerRepository.loadRegisterData(0, registerId) } returns listOf(RepositoryResourceData(resource = Faker.buildPatient())) @@ -72,9 +81,6 @@ class RegisterPagingSourceTest : RobolectricTest() { every { loadParams.key } returns null runBlocking { registerPagingSource.run { - setPatientPagingSourceState( - RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = false), - ) val result = load(loadParams) Assert.assertNotNull(result) Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) @@ -113,10 +119,18 @@ class RegisterPagingSourceTest : RobolectricTest() { registerPagingSource = RegisterPagingSource( registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, - ruleConfigs = listOf(), - actionParameters = emptyMap(), fhirResourceConfig = fhirResourceConfig, + actionParameters = emptyMap(), + registerPagingSourceState = + RegisterPagingSourceState( + registerId = registerId, + currentPage = 0, + loadAll = false, + Rules( + setOf(), + ), + ), + rulesExecutor = rulesExecutor, ) coEvery { registerRepository.loadRegisterData( @@ -128,7 +142,8 @@ class RegisterPagingSourceTest : RobolectricTest() { listOf( RepositoryResourceData( resource = baseResource, - relatedResourcesMap = relatedResources.groupBy { it.resourceType.name }, + relatedResourcesMap = + relatedResources.groupBy { it.resourceType.name }.toMap(ConcurrentHashMap()), ), ) @@ -136,9 +151,6 @@ class RegisterPagingSourceTest : RobolectricTest() { every { loadParams.key } returns null runBlocking { registerPagingSource.run { - setPatientPagingSourceState( - RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = false), - ) val result = load(loadParams) Assert.assertNotNull(result) Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) @@ -151,19 +163,25 @@ class RegisterPagingSourceTest : RobolectricTest() { registerPagingSource = RegisterPagingSource( registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, - ruleConfigs = listOf(), - actionParameters = emptyMap(), fhirResourceConfig = null, + actionParameters = emptyMap(), + registerPagingSourceState = + RegisterPagingSourceState( + registerId = registerId, + currentPage = 0, + loadAll = false, + rules = + Rules( + setOf(), + ), + ), + rulesExecutor = rulesExecutor, ) coEvery { registerRepository.loadRegisterData(0, registerId) } returns emptyList() val loadParams = mockk>() every { loadParams.key } returns null runBlocking { registerPagingSource.run { - setPatientPagingSourceState( - RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = false), - ) val result = load(loadParams) Assert.assertNotNull(result) Assert.assertTrue((result as PagingSource.LoadResult.Page).data.isEmpty()) @@ -176,10 +194,19 @@ class RegisterPagingSourceTest : RobolectricTest() { registerPagingSource = RegisterPagingSource( registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, - ruleConfigs = listOf(), - actionParameters = emptyMap(), fhirResourceConfig = null, + actionParameters = emptyMap(), + registerPagingSourceState = + RegisterPagingSourceState( + registerId = registerId, + currentPage = 2, + loadAll = false, + rules = + Rules( + setOf(), + ), + ), + rulesExecutor = rulesExecutor, ) coEvery { registerRepository.loadRegisterData(2, registerId) } returns listOf(RepositoryResourceData(resource = Faker.buildPatient())) @@ -187,9 +214,6 @@ class RegisterPagingSourceTest : RobolectricTest() { every { loadParams.key } returns 2 runBlocking { registerPagingSource.run { - setPatientPagingSourceState( - RegisterPagingSourceState(registerId = registerId, currentPage = 2, loadAll = false), - ) val result = load(loadParams) Assert.assertNotNull(result) Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) @@ -202,10 +226,19 @@ class RegisterPagingSourceTest : RobolectricTest() { registerPagingSource = RegisterPagingSource( registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, - ruleConfigs = listOf(), - actionParameters = emptyMap(), fhirResourceConfig = null, + actionParameters = emptyMap(), + registerPagingSourceState = + RegisterPagingSourceState( + registerId = registerId, + currentPage = 0, + loadAll = true, + rules = + Rules( + setOf(), + ), + ), + rulesExecutor = rulesExecutor, ) coEvery { registerRepository.loadRegisterData(0, registerId) } returns listOf(RepositoryResourceData(resource = Faker.buildPatient())) @@ -215,9 +248,6 @@ class RegisterPagingSourceTest : RobolectricTest() { every { loadParams.key } returns null runBlocking { registerPagingSource.run { - setPatientPagingSourceState( - RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = true), - ) var result = load(loadParams) Assert.assertNotNull(result) Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) @@ -230,29 +260,35 @@ class RegisterPagingSourceTest : RobolectricTest() { } @Test - fun testLoadWithNonEmptyActionParametersShouldReturnResults() { + fun testLoadWithNonEmptyActionParametersShouldReturnResults() = runTest { val actionParameters = mapOf("param1" to "value1") registerPagingSource = RegisterPagingSource( registerRepository = registerRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, - ruleConfigs = listOf(), - actionParameters = actionParameters, fhirResourceConfig = null, + actionParameters = actionParameters, + registerPagingSourceState = + RegisterPagingSourceState( + registerId = registerId, + currentPage = 0, + loadAll = false, + rules = Rules(setOf()), + ), + rulesExecutor = rulesExecutor, ) - coEvery { registerRepository.loadRegisterData(0, registerId) } returns - listOf(RepositoryResourceData(resource = Faker.buildPatient())) + coEvery { + registerRepository.loadRegisterData( + currentPage = 0, + registerId = registerId, + paramsMap = actionParameters, + ) + } returns listOf(RepositoryResourceData(resource = Faker.buildPatient())) val loadParams = mockk>() every { loadParams.key } returns null - runBlocking { - registerPagingSource.run { - setPatientPagingSourceState( - RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = false), - ) - val result = load(loadParams) - Assert.assertNotNull(result) - Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) - } + registerPagingSource.run { + val result = load(loadParams) + Assert.assertNotNull(result) + Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) } } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSourceTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSourceTest.kt index fef58e2a9d..f7c37de29c 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSourceTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSourceTest.kt @@ -32,6 +32,7 @@ import io.mockk.spyk import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import org.apache.commons.jexl3.JexlEngine import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.ResourceType import org.junit.Assert @@ -47,7 +48,7 @@ import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.rulesengine.RulesFactory import org.smartregister.fhircore.engine.rulesengine.services.LocationService import org.smartregister.fhircore.engine.util.DispatcherProvider @@ -71,11 +72,13 @@ class MeasureReportPagingSourceTest : RobolectricTest() { @Inject lateinit var fhirContext: FhirContext + @Inject lateinit var jexlEngine: JexlEngine + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private val fhirEngine: FhirEngine = mockk() private val registerId = "register id" private lateinit var rulesFactory: RulesFactory - private lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + private lateinit var rulesExecutor: RulesExecutor private lateinit var measureReportConfiguration: MeasureReportConfiguration private lateinit var measureReportPagingSource: MeasureReportPagingSource private lateinit var registerRepository: RegisterRepository @@ -96,9 +99,10 @@ class MeasureReportPagingSourceTest : RobolectricTest() { locationService = locationService, fhirContext = fhirContext, defaultRepository = defaultRepository, + jexlEngine = jexlEngine, ), ) - resourceDataRulesExecutor = ResourceDataRulesExecutor(rulesFactory) + rulesExecutor = RulesExecutor(rulesFactory) val appId = "appId" val id = "id" @@ -127,7 +131,7 @@ class MeasureReportPagingSourceTest : RobolectricTest() { measureReportConfiguration, registerConfiguration, registerRepository, - resourceDataRulesExecutor, + rulesExecutor, ) } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepositoryTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepositoryTest.kt index fc2e1b22c0..8410d9d7f3 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepositoryTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepositoryTest.kt @@ -20,6 +20,7 @@ import androidx.test.core.app.ApplicationProvider import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.FhirEngine import com.google.android.fhir.SearchResult +import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.knowledge.KnowledgeManager import com.google.android.fhir.workflow.FhirOperator import dagger.hilt.android.testing.HiltAndroidRule @@ -34,23 +35,27 @@ import javax.inject.Inject import kotlin.test.assertEquals import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import org.apache.commons.jexl3.JexlEngine import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.ResourceType import org.junit.Before import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.report.measure.MeasureReportConfiguration import org.smartregister.fhircore.engine.configuration.report.measure.ReportConfiguration import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.register.RegisterRepository -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.rulesengine.RulesFactory import org.smartregister.fhircore.engine.rulesengine.services.LocationService import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MM_DD import org.smartregister.fhircore.engine.util.extension.firstDayOfMonth import org.smartregister.fhircore.engine.util.extension.formatDate @@ -78,16 +83,22 @@ class MeasureReportRepositoryTest : RobolectricTest() { @Inject lateinit var contentCache: ContentCache - private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() - private val fhirEngine: FhirEngine = mockk() + @Inject lateinit var jexlEngine: JexlEngine + + @Inject lateinit var configService: ConfigService + + @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper + private lateinit var measureReportConfiguration: MeasureReportConfiguration private lateinit var measureReportRepository: MeasureReportRepository - private val registerId = "register id" + private lateinit var defaultRepository: DefaultRepository private lateinit var rulesFactory: RulesFactory - private lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + private lateinit var rulesExecutor: RulesExecutor private lateinit var registerRepository: RegisterRepository + private val registerId = "register id" + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + private val fhirEngine: FhirEngine = mockk() private val parser = FhirContext.forR4Cached().newJsonParser() - private lateinit var defaultRepository: DefaultRepository @Before @kotlinx.coroutines.ExperimentalCoroutinesApi @@ -104,9 +115,10 @@ class MeasureReportRepositoryTest : RobolectricTest() { locationService = locationService, fhirContext = fhirContext, defaultRepository = defaultRepository, + jexlEngine = jexlEngine, ), ) - resourceDataRulesExecutor = ResourceDataRulesExecutor(rulesFactory) + rulesExecutor = RulesExecutor(rulesFactory) val appId = "appId" val id = "id" @@ -117,9 +129,9 @@ class MeasureReportRepositoryTest : RobolectricTest() { RegisterRepository( fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider, - sharedPreferencesHelper = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, configurationRegistry = configurationRegistry, - configService = mockk(), + configService = configService, configRulesExecutor = mockk(), fhirPathDataExtractor = mockk(), parser = parser, @@ -131,9 +143,9 @@ class MeasureReportRepositoryTest : RobolectricTest() { measureReportRepository = MeasureReportRepository( fhirEngine = fhirEngine, - sharedPreferencesHelper = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, configurationRegistry = configurationRegistry, - configService = mockk(), + configService = configService, configRulesExecutor = mockk(), fhirOperator = fhirOperator, knowledgeManager = knowledgeManager, @@ -211,15 +223,15 @@ class MeasureReportRepositoryTest : RobolectricTest() { @kotlinx.coroutines.ExperimentalCoroutinesApi fun testRetrieveSubjectsWithResultsNonEmptySubjectXFhirWithGroupUpdates() { val reportConfiguration = ReportConfiguration(subjectXFhirQuery = "Patient") + val resource = Group().apply { id = "grp1" } coEvery { fhirEngine.search(any()) } returns - listOf(SearchResult(resource = Group(), null, null)) + listOf(SearchResult(resource = resource, null, null)) + coEvery { fhirEngine.get(ResourceType.Group, resource.logicalId) } returns resource coEvery { fhirEngine.update(any()) } just runs - runBlocking(Dispatchers.Default) { val data = measureReportRepository.fetchSubjects(reportConfiguration) assertEquals(1, data.size) } - coVerify { fhirEngine.search(any()) } coVerify { fhirEngine.update(any()) } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt index dcfa53e5ea..58553fa1a1 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt @@ -45,7 +45,7 @@ import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.quest.app.fakes.Faker @@ -63,7 +63,7 @@ class GeoWidgetLauncherViewModelTest : RobolectricTest() { @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper - @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var rulesExecutor: RulesExecutor private lateinit var applicationContext: Context @@ -103,7 +103,7 @@ class GeoWidgetLauncherViewModelTest : RobolectricTest() { defaultRepository = defaultRepository, dispatcherProvider = dispatcherProvider, sharedPreferencesHelper = sharedPreferencesHelper, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, configurationRegistry = configurationRegistry, context = applicationContext, ) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragmentTest.kt index 089ab4260e..aa99d157ae 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragmentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragmentTest.kt @@ -122,7 +122,7 @@ class ProfileFragmentTest : RobolectricTest() { TestNavHostController(mainActivity).apply { setGraph(R.navigation.application_nav_graph) } // Simulate the returned value of loadProfile - coEvery { registerRepository.loadProfileData(any(), any(), paramsList = emptyArray()) } returns + coEvery { registerRepository.loadProfileData(any(), any(), paramsMap = emptyMap()) } returns RepositoryResourceData(resource = Faker.buildPatient()) mainActivity.supportFragmentManager.run { commitNow { add(profileFragment, ProfileFragment::class.java.simpleName) } @@ -155,10 +155,11 @@ class ProfileFragmentTest : RobolectricTest() { coVerify { profileViewModel.retrieveProfileUiState( + context = ApplicationProvider.getApplicationContext(), profileId = "defaultProfile", resourceId = "sampleId", - any(), - any(), + fhirResourceConfig = any(), + paramsList = any(), ) } coVerify { profileViewModel.emitSnackBarState(snackBarMessageConfig) } @@ -190,10 +191,11 @@ class ProfileFragmentTest : RobolectricTest() { coVerify { profileViewModel.retrieveProfileUiState( + context = ApplicationProvider.getApplicationContext(), profileId = "defaultProfile", resourceId = "sampleId", - any(), - any(), + fhirResourceConfig = any(), + paramsList = any(), ) } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModelTest.kt index 56834abcbd..4e88825dd3 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModelTest.kt @@ -54,7 +54,8 @@ import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.domain.model.OverflowMenuItemConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceData -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.BLACK_COLOR_HEX_CODE import org.smartregister.fhircore.engine.util.extension.getActivity @@ -72,7 +73,9 @@ class ProfileViewModelTest : RobolectricTest() { @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor - @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var rulesExecutor: RulesExecutor + + @Inject lateinit var configRulesExecutor: ConfigRulesExecutor @Inject lateinit var dispatcherProvider: DispatcherProvider @@ -105,19 +108,19 @@ class ProfileViewModelTest : RobolectricTest() { sharedPreferencesHelper = mockk(), configurationRegistry = configurationRegistry, configService = mockk(), - configRulesExecutor = mockk(), fhirPathDataExtractor = mockk(), parser = parser, context = ApplicationProvider.getApplicationContext(), dispatcherProvider = dispatcherProvider, contentCache = contentCache, + configRulesExecutor = configRulesExecutor, ), ) coEvery { registerRepository.loadProfileData( - any(), - any(), - paramsList = emptyArray(), + profileId = any(), + resourceId = any(), + paramsMap = emptyMap(), ) } returns RepositoryResourceData(resource = Faker.buildPatient()) @@ -134,7 +137,7 @@ class ProfileViewModelTest : RobolectricTest() { configurationRegistry = configurationRegistry, dispatcherProvider = dispatcherProvider, fhirPathDataExtractor = fhirPathDataExtractor, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, ) } @@ -143,8 +146,9 @@ class ProfileViewModelTest : RobolectricTest() { fun testRetrieveProfileUiState() { runBlocking { profileViewModel.retrieveProfileUiState( - "householdProfile", - "sampleId", + context = ApplicationProvider.getApplicationContext(), + profileId = "householdProfile", + resourceId = "sampleId", paramsList = emptyArray(), ) } @@ -259,7 +263,7 @@ class ProfileViewModelTest : RobolectricTest() { configurationRegistry, dispatcherProvider, fhirPathDataExtractor, - resourceDataRulesExecutor, + rulesExecutor, ) val managingEntityConfig = diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt index 14fbdcfae7..4bcddcd838 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt @@ -44,7 +44,7 @@ import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @@ -65,7 +65,7 @@ class QuestionnaireDraftDialogViewModelTest : RobolectricTest() { @Inject lateinit var configService: ConfigService - @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var rulesExecutor: RulesExecutor @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt index e2b95eaaa4..894fe1f9ee 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt @@ -102,7 +102,7 @@ import org.smartregister.fhircore.engine.domain.model.ActionParameterType import org.smartregister.fhircore.engine.domain.model.QuestionnaireType import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.task.FhirCarePlanGenerator import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey @@ -138,7 +138,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { @Inject lateinit var configService: ConfigService - @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var rulesExecutor: RulesExecutor @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor @@ -214,7 +214,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { defaultRepository = defaultRepository, dispatcherProvider = dispatcherProvider, fhirCarePlanGenerator = fhirCarePlanGenerator, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, @@ -702,7 +702,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { defaultRepository = defaultRepository, dispatcherProvider = dispatcherProvider, fhirCarePlanGenerator = fhirCarePlanGenerator, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, @@ -1358,6 +1358,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { } @Test + @Ignore("This test is flaky investigate and fix") fun testSoftDeleteShouldTriggerDefaultRepositoryUpdateResourceFunction() = runTest { val patient = Faker.buildPatient() val theQuestionnaireConfig = @@ -1910,7 +1911,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { defaultRepository = defaultRepository, dispatcherProvider = dispatcherProvider, fhirCarePlanGenerator = fhirCarePlanGenerator, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, @@ -1972,7 +1973,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { defaultRepository = defaultRepository, dispatcherProvider = dispatcherProvider, fhirCarePlanGenerator = fhirCarePlanGenerator, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, @@ -2047,7 +2048,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { defaultRepository = defaultRepository, dispatcherProvider = dispatcherProvider, fhirCarePlanGenerator = fhirCarePlanGenerator, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, @@ -2155,7 +2156,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { defaultRepository = defaultRepository, dispatcherProvider = dispatcherProvider, fhirCarePlanGenerator = fhirCarePlanGenerator, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt index a4a903b17e..cdc1fa1d92 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt @@ -89,7 +89,7 @@ class RegisterFragmentTest : RobolectricTest() { registerRepository = mockk(relaxed = true), configurationRegistry = configurationRegistry, sharedPreferencesHelper = Faker.buildSharedPreferencesHelper(), - resourceDataRulesExecutor = mockk(), + rulesExecutor = mockk(), dispatcherProvider = dispatcherProvider, ), ) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt index 8f7e92738f..a85d5cdb04 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt @@ -58,7 +58,7 @@ import org.smartregister.fhircore.engine.domain.model.DataQuery import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.FilterCriterionConfig import org.smartregister.fhircore.engine.domain.model.ResourceConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @@ -70,7 +70,7 @@ import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery class RegisterViewModelTest : RobolectricTest() { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) - @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var rulesExecutor: RulesExecutor @Inject lateinit var dispatcherProvider: DispatcherProvider @@ -93,7 +93,7 @@ class RegisterViewModelTest : RobolectricTest() { registerRepository = registerRepository, configurationRegistry = configurationRegistry, sharedPreferencesHelper = sharedPreferencesHelper, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, dispatcherProvider = dispatcherProvider, ), ) @@ -169,9 +169,7 @@ class RegisterViewModelTest : RobolectricTest() { coEvery { registerRepository.countRegisterData(any()) } returns 0L val results = mutableListOf() - val debounceJob = launch { - registerViewModel.debouncedSearchQueryFlow.collect { results.add(it.query) } - } + val debounceJob = launch { registerViewModel.searchQueryFlow.collect { results.add(it.query) } } advanceUntilIdle() // Search with empty string should paginate the data @@ -187,7 +185,7 @@ class RegisterViewModelTest : RobolectricTest() { registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Khan"))) advanceTimeBy(1010.milliseconds) - Assert.assertEquals(2, results.size) + Assert.assertEquals(5, results.size) Assert.assertEquals("Khan", results.last()) debounceJob.cancel() } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModelTest.kt index 5949ebf6e7..69ecd0c9e5 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModelTest.kt @@ -77,7 +77,7 @@ import org.smartregister.fhircore.engine.configuration.report.measure.ReportConf import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.ResourceData -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.rulesengine.RulesExecutor import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.MeasurePopulationType import org.smartregister.fhircore.engine.util.extension.SDF_MMMM @@ -107,7 +107,7 @@ class MeasureReportViewModelTest : RobolectricTest() { @Inject lateinit var defaultRepository: DefaultRepository - @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var rulesExecutor: RulesExecutor @OptIn(ExperimentalCoroutinesApi::class) private val unconfinedTestDispatcher = UnconfinedTestDispatcher() @@ -147,7 +147,7 @@ class MeasureReportViewModelTest : RobolectricTest() { configurationRegistry = configurationRegistry, registerRepository = registerRepository, defaultRepository = defaultRepository, - resourceDataRulesExecutor = resourceDataRulesExecutor, + rulesExecutor = rulesExecutor, measureReportRepository = measureReportRepository, ), ) From aef0fdc9743d7022400849d2b6a696a5064328cd Mon Sep 17 00:00:00 2001 From: Fikri Milano Date: Fri, 6 Dec 2024 22:11:45 +0700 Subject: [PATCH 04/12] Add configurable confirmation dialog on form submission (#3619) * Add feature * spotless * Add notes to CHANGELOG.md * Add doc * Fix post merge conflict error * spotless --- CHANGELOG.md | 1 + .../configuration/QuestionnaireConfig.kt | 3 + .../ui/questionnaire/QuestionnaireActivity.kt | 95 ++++++++++++------- android/quest/src/main/res/values/strings.xml | 2 + .../app/configuring/forms/forms.mdx | 2 + 5 files changed, 67 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b01eff17..57c1828cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 3. Implemented functionality to launch PDF generation using a configuration setup - 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/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/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index e0608a8062..9ed3132b57 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -52,6 +52,7 @@ import org.smartregister.fhircore.engine.domain.model.isReadOnly import org.smartregister.fhircore.engine.domain.model.isSummary import org.smartregister.fhircore.engine.ui.base.AlertDialogButton import org.smartregister.fhircore.engine.ui.base.AlertDialogue +import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.encodeResourceToString @@ -304,44 +305,66 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { QuestionnaireFragment.SUBMIT_REQUEST_KEY, this, ) { _, _ -> - lifecycleScope.launch { - val questionnaireResponse = retrieveQuestionnaireResponse() + if (questionnaireConfig.showSubmissionConfirmationDialog.toBooleanStrict()) { + AlertDialogue.showAlert( + context = this, + alertIntent = AlertIntent.CONFIRM, + message = getString(R.string.questionnaire_submission_confirmation_message), + title = getString(R.string.questionnaire_submission_confirmation_title), + confirmButton = + AlertDialogButton( + listener = { processSubmission() }, + ), + neutralButton = + AlertDialogButton( + text = R.string.no, + listener = { it.dismiss() }, + ), + ) + } else { + processSubmission() + } + } + } - // Close questionnaire if opened in read only mode or if experimental - if (questionnaireConfig.isReadOnly() || questionnaire?.experimental == true) { - finish() - } - if (questionnaireResponse != null && questionnaire != null) { - viewModel.run { - setProgressState(QuestionnaireProgressState.ExtractionInProgress(true)) - - if (currentLocation != null) { - questionnaireResponse.contained.add( - ResourceUtils.createFhirLocationFromGpsLocation(gpsLocation = currentLocation!!), - ) - } + private fun processSubmission() { + lifecycleScope.launch { + val questionnaireResponse = retrieveQuestionnaireResponse() - handleQuestionnaireSubmission( - questionnaire = questionnaire!!, - currentQuestionnaireResponse = questionnaireResponse, - questionnaireConfig = questionnaireConfig, - actionParameters = actionParameters, - context = this@QuestionnaireActivity, - ) { idTypes, questionnaireResponse -> - // Dismiss progress indicator dialog, submit result then finish activity - // TODO Ensure this dialog is dismissed even when an exception is encountered - setProgressState(QuestionnaireProgressState.ExtractionInProgress(false)) - setResult( - Activity.RESULT_OK, - Intent().apply { - putExtra(QUESTIONNAIRE_RESPONSE, questionnaireResponse as Serializable) - putExtra(QUESTIONNAIRE_SUBMISSION_EXTRACTED_RESOURCE_IDS, idTypes as Serializable) - putExtra(QUESTIONNAIRE_CONFIG, questionnaireConfig as Parcelable) - putExtra(ON_RESULT_TYPE, ActivityOnResultType.QUESTIONNAIRE.name) - }, - ) - finish() - } + // Close questionnaire if opened in read only mode or if experimental + if (questionnaireConfig.isReadOnly() || questionnaire?.experimental == true) { + finish() + } + if (questionnaireResponse != null && questionnaire != null) { + viewModel.run { + setProgressState(QuestionnaireProgressState.ExtractionInProgress(true)) + + if (currentLocation != null) { + questionnaireResponse.contained.add( + ResourceUtils.createFhirLocationFromGpsLocation(gpsLocation = currentLocation!!), + ) + } + + handleQuestionnaireSubmission( + questionnaire = questionnaire!!, + currentQuestionnaireResponse = questionnaireResponse, + questionnaireConfig = questionnaireConfig, + actionParameters = actionParameters, + context = this@QuestionnaireActivity, + ) { idTypes, questionnaireResponse -> + // Dismiss progress indicator dialog, submit result then finish activity + // TODO Ensure this dialog is dismissed even when an exception is encountered + setProgressState(QuestionnaireProgressState.ExtractionInProgress(false)) + setResult( + Activity.RESULT_OK, + Intent().apply { + putExtra(QUESTIONNAIRE_RESPONSE, questionnaireResponse as Serializable) + putExtra(QUESTIONNAIRE_SUBMISSION_EXTRACTED_RESOURCE_IDS, idTypes as Serializable) + putExtra(QUESTIONNAIRE_CONFIG, questionnaireConfig as Parcelable) + putExtra(ON_RESULT_TYPE, ActivityOnResultType.QUESTIONNAIRE.name) + }, + ) + finish() } } } diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 9c96b97c3d..4e1f1588a9 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -142,4 +142,6 @@ %1$d matching location(s) rendered successfully" Cancel adding location Error rendering profile + Are you sure you want to submit? + You are about to submit diff --git a/docs/engineering/app/configuring/forms/forms.mdx b/docs/engineering/app/configuring/forms/forms.mdx index f5d4878e95..1095718387 100644 --- a/docs/engineering/app/configuring/forms/forms.mdx +++ b/docs/engineering/app/configuring/forms/forms.mdx @@ -126,6 +126,8 @@ These are used when generating other tasks, CarePlans and related resources.See | onSubmitActions | Configurations for actions invoked post Questionnaire submission | no | null | | extractedResourceUniquePropertyExpressions | Configurations for unique properties used to identify resources during Questionnaire edit | no | null | | uniqueIdAssignment | Configuration for unique identifier assignment | no | null | +| showSubmitAnywayButton | When a form submission detects a validation error, the app will display a validation error dialog that prevents the user from submitting the form. Enabling this property will show a button in the validation error dialog. When pressed, the form will be submitted regardless of the validation error. | no | false | +| showSubmissionConfirmationDialog | Display a submission confirmation dialog once the submit button is pressed | no | false | ## Dynamic data pass between Profiles and Questionnaires From d38129e0bd39c743cea67d8f51b0faf7f4241535 Mon Sep 17 00:00:00 2001 From: Simon Kiarie <696759+qiarie@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:19:26 +0300 Subject: [PATCH 05/12] Update max heap size from 4 to 6 GB (#3651) --- android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 4163bed138..103cfe3327 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -9,7 +9,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4608m +org.gradle.jvmargs=-Xmx6144m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit From 750614325788773ec0120aa5a280fc5cbcb00057 Mon Sep 17 00:00:00 2001 From: Francis Odhiambo <4540684+f-odhiambo@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:13:18 +0300 Subject: [PATCH 06/12] Initial commit (#3652) --- android/quest/src/contigo/res/drawable/ic_check.xml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 android/quest/src/contigo/res/drawable/ic_check.xml diff --git a/android/quest/src/contigo/res/drawable/ic_check.xml b/android/quest/src/contigo/res/drawable/ic_check.xml new file mode 100644 index 0000000000..122d978c57 --- /dev/null +++ b/android/quest/src/contigo/res/drawable/ic_check.xml @@ -0,0 +1,4 @@ + + + From 1feead068b272ae7a5a49246c76f7cb6b67585e7 Mon Sep 17 00:00:00 2001 From: Rkareko <47570855+Rkareko@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:55:42 +0300 Subject: [PATCH 07/12] Fix formatting issue in save draft docs (#3649) --- .../engineering/app/configuring/forms/save-form-as-draft.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx index a6457a835b..724298f013 100644 --- a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx +++ b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx @@ -54,10 +54,11 @@ A dialog appears with 3 buttons i.e `Save as draft`, `Discard changes` and `Canc The table below details what each of the buttons does. #### Alert dialog buttons descriptions + |Button | Description | -|--|--|:--:|:--:| +|:--|:--| Save as draft | Saves user input as a draft | -Discard changes | Dismisses user input, and closes the form without saving the draft. | +Discard changes | Dismisses user input, and closes the form without saving the draft | Cancel | Dismisses the dialog so that the user can continue interacting with the form | ## Launching save draft from DELETE_DRAFT_QUESTIONNAIRE workflow From a795d9c7ab0b191f3fc2591d7318c94b7083ccc9 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Wed, 11 Dec 2024 18:08:56 +0300 Subject: [PATCH 08/12] Improve visibility on loading map data and sync UX (#3621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement LineSpinFadeLoaderProgressIndicator Signed-off-by: Elly Kitoto * Run spotless Signed-off-by: Elly Kitoto * Fix spacing Signed-off-by: Elly Kitoto * Show the loader dialog when syncing the location data (#3598) * Show the LoaderDialog when syncing the Location data Update the LoaderDialog: - Make box and progressBar size dynamic - Make the progressDialog message as optional - Add the ability to show either the ProgressBar or LineSpinFadeLoaderProgressIndicator * Add an option in the LoaderDialog to show a dialog with non blocking UI Show a non blocking dialog when applying location filtering * clean-up: rename variables * fix tests * 🚧 Increase the loading dialog size on the geowidget screen * Fix showing map snackbar messages Signed-off-by: Elly Kitoto * Formate code Signed-off-by: Elly Kitoto --------- Signed-off-by: Elly Kitoto Co-authored-by: qaziabubakar-vd <72507786+qaziabubakar-vd@users.noreply.github.com> Co-authored-by: Benjamin Mwalimu Co-authored-by: qaziabubakar-vd --- android/engine/build.gradle.kts | 3 +- .../LineSpinFadeLoaderProgressIndicator.kt | 149 +++++++++++++++++ .../ui/components/register/LoaderDialog.kt | 151 +++++++++++++----- .../ui/geowidget/GeoWidgetLauncherFragment.kt | 1 + .../ui/geowidget/GeoWidgetLauncherScreen.kt | 39 ++++- .../geowidget/GeoWidgetLauncherViewModel.kt | 53 +++--- .../ui/shared/components/SyncStatusView.kt | 130 ++++++++------- 7 files changed, 398 insertions(+), 128 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/LineSpinFadeLoaderProgressIndicator.kt diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index 753fbae6ce..9d90fcc3d3 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) 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/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt index 2eab074a82..18bb4efdf4 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt @@ -195,6 +195,7 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { launchQuestionnaire = geoWidgetLauncherViewModel::launchQuestionnaire, decodeImage = geoWidgetLauncherViewModel::getImageBitmap, onAppMainEvent = appMainViewModel::onEvent, + isSyncing = geoWidgetLauncherViewModel.isSyncing, ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt index 86dc61b6bc..516a7c460c 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt @@ -18,17 +18,25 @@ package org.smartregister.fhircore.quest.ui.geowidget import android.content.Context import android.graphics.Bitmap +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import androidx.fragment.compose.AndroidFragment import androidx.fragment.compose.rememberFragmentState +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.navigation.NavController import org.hl7.fhir.r4.model.ResourceType @@ -36,6 +44,7 @@ import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation +import org.smartregister.fhircore.engine.ui.components.register.LoaderDialog import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.geowidget.model.GeoJsonFeature import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment @@ -65,8 +74,11 @@ fun GeoWidgetLauncherScreen( launchQuestionnaire: (QuestionnaireConfig, GeoJsonFeature, Context) -> Unit, decodeImage: ((String) -> Bitmap?)?, onAppMainEvent: (AppMainEvent) -> Unit, + isSyncing: LiveData, ) { val context = LocalContext.current + val syncing by isSyncing.observeAsState() + Scaffold( topBar = { Column { @@ -118,14 +130,20 @@ fun GeoWidgetLauncherScreen( }, ) { innerPadding -> val fragmentState = rememberFragmentState() - Box(modifier = modifier.padding(innerPadding)) { + Box( + modifier = modifier.padding(innerPadding).fillMaxSize(), + ) { AndroidFragment(fragmentState = fragmentState) { fragment -> fragment .setUseGpsOnAddingLocation(false) .setAddLocationButtonVisibility(geoWidgetConfiguration.showAddLocation) .setOnAddLocationListener { feature: GeoJsonFeature -> if (feature.geometry?.coordinates == null) return@setOnAddLocationListener - launchQuestionnaire(geoWidgetConfiguration.registrationQuestionnaire, feature, context) + launchQuestionnaire( + geoWidgetConfiguration.registrationQuestionnaire, + feature, + context, + ) } .setOnCancelAddingLocationListener { context.showToast(context.getString(R.string.on_cancel_adding_location)) @@ -153,6 +171,23 @@ fun GeoWidgetLauncherScreen( observerGeoJsonFeatures(geoJsonFeatures) } } + if (syncing == true) { + Box( + modifier = + Modifier.fillMaxSize().padding(16.dp).pointerInput(Unit) { detectTapGestures {} }, + contentAlignment = Alignment.Center, + ) { + LoaderDialog( + boxWidth = 100.dp, + boxHeight = 100.dp, + progressBarSize = 130.dp, + showBackground = true, + showLineSpinIndicator = true, + showOverlay = false, + modifier = Modifier.align(Alignment.Center), + ) + } + } } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt index 8f7ae4b20e..e755cc748f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt @@ -75,6 +75,9 @@ constructor( private val _snackBarStateFlow = MutableSharedFlow() val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() + private val _isSyncing = MutableLiveData(false) + val isSyncing: LiveData = _isSyncing + private val _noLocationFoundDialog = MutableLiveData() val noLocationFoundDialog: LiveData get() = _noLocationFoundDialog @@ -98,6 +101,7 @@ constructor( searchText: String?, ) { viewModelScope.launch { + _isSyncing.postValue(true) val (locationsWithCoordinates, locationsWithoutCoordinates) = defaultRepository .searchNestedResources( @@ -159,13 +163,15 @@ constructor( } else { registerData.filter { geoJsonFeature: GeoJsonFeature -> geoWidgetConfig.topScreenSection?.searchBar?.computedRules?.any { ruleName -> - // if ruleName not found in map return {-1}; check always return false hence no data + // if ruleName not found in map return {-1}; check always return false hence no + // data val value = geoJsonFeature.properties[ruleName]?.toString() ?: "{-1}" value.contains(other = searchText, ignoreCase = true) } == true } } + _isSyncing.postValue(false) geoJsonFeatures.postValue(features) Timber.w( @@ -197,30 +203,11 @@ constructor( ), ) } else { - val message = - if (searchText.isNullOrBlank()) { - context.getString(R.string.all_locations_rendered) - } else { - context.getString(R.string.all_matching_locations_rendered, locationsCount) - } - emitSnackBarState( - SnackBarMessageConfig( - message = message, - actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), - duration = SnackbarDuration.Short, - ), - ) - } - - // Account for missing locations - if (locationsCount == 0) { - if (!searchText.isNullOrBlank()) { + if (locationsCount == 0) { val message = - context.getString( - R.string.no_found_locations_matching_text, - searchText, - ) - Timber.w(message) + if (!searchText.isNullOrBlank()) { + context.getString(R.string.no_found_locations_matching_text, searchText) + } else context.getString(R.string.no_locations_to_render) emitSnackBarState( SnackBarMessageConfig( message = message, @@ -228,12 +215,22 @@ constructor( duration = SnackbarDuration.Long, ), ) + Timber.w(message) } else { - SnackBarMessageConfig( - message = context.getString(R.string.no_locations_to_render), - actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), - duration = SnackbarDuration.Long, + val message = + if (searchText.isNullOrBlank()) { + context.getString(R.string.all_locations_rendered) + } else { + context.getString(R.string.all_matching_locations_rendered, locationsCount) + } + emitSnackBarState( + SnackBarMessageConfig( + message = message, + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Short, + ), ) + Timber.w(message) } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt index 423323e660..cb371dfb20 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt @@ -19,6 +19,7 @@ package org.smartregister.fhircore.quest.ui.shared.components import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -63,6 +64,7 @@ import java.time.OffsetDateTime import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.ui.components.LineSpinFadeLoaderProgressIndicator import org.smartregister.fhircore.engine.ui.theme.AppTheme import org.smartregister.fhircore.engine.ui.theme.DangerColor import org.smartregister.fhircore.engine.ui.theme.DefaultColor @@ -267,68 +269,86 @@ fun SyncStatusView( } if (currentSyncJobStatus is CurrentSyncJobStatus.Running) { - Column(modifier = Modifier.weight(1f)) { - if (!minimized) { - SyncStatusTitle( - text = - stringResource( - if (isSyncUpload == true) { - org.smartregister.fhircore.engine.R.string.sync_up_inprogress - } else { - org.smartregister.fhircore.engine.R.string.sync_down_inprogress - }, - progressPercentage ?: 0, - ), - minimized = false, - color = Color.White, - startPadding = 0, - ) - } - LinearProgressIndicator( - progress = (progressPercentage?.toFloat()?.div(100)) ?: 0f, - color = MaterialTheme.colors.primary, - backgroundColor = Color.White, - modifier = - Modifier.testTag(SYNC_PROGRESS_INDICATOR_TEST_TAG) - .padding(vertical = 4.dp) - .fillMaxWidth(), - ) - if (!minimized) { - Text( - text = stringResource(id = org.smartregister.fhircore.engine.R.string.please_wait), - color = SubtitleTextColor, - fontSize = 14.sp, - textAlign = TextAlign.Start, - modifier = Modifier.align(Alignment.Start), + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.weight(1f), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (!minimized) { + SyncStatusTitle( + text = + stringResource( + if (isSyncUpload == true) { + org.smartregister.fhircore.engine.R.string.sync_up_inprogress + } else { + org.smartregister.fhircore.engine.R.string.sync_down_inprogress + }, + progressPercentage ?: 0, + ), + minimized = false, + color = Color.White, + startPadding = 0, + ) + } + LinearProgressIndicator( + progress = (progressPercentage?.toFloat()?.div(100)) ?: 0f, + color = MaterialTheme.colors.primary, + backgroundColor = Color.White, + modifier = + Modifier.testTag(SYNC_PROGRESS_INDICATOR_TEST_TAG) + .padding(vertical = 4.dp) + .fillMaxWidth(), ) + if (!minimized) { + Text( + text = stringResource(id = org.smartregister.fhircore.engine.R.string.please_wait), + color = SubtitleTextColor, + fontSize = 14.sp, + textAlign = TextAlign.Start, + modifier = Modifier.align(Alignment.Start), + ) + } } } } - if ( - (currentSyncJobStatus is CurrentSyncJobStatus.Failed || - currentSyncJobStatus is CurrentSyncJobStatus.Running) && !minimized + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), ) { - Text( - text = - stringResource( - if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { - org.smartregister.fhircore.engine.R.string.retry - } else { - org.smartregister.fhircore.engine.R.string.cancel + if (currentSyncJobStatus is CurrentSyncJobStatus.Running) { + LineSpinFadeLoaderProgressIndicator( + color = Color.White, + lineLength = 8f, + innerRadius = 12f, + ) + } + if ( + (currentSyncJobStatus is CurrentSyncJobStatus.Failed || + currentSyncJobStatus is CurrentSyncJobStatus.Running) && !minimized + ) { + Text( + text = + stringResource( + if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { + org.smartregister.fhircore.engine.R.string.retry + } else { + org.smartregister.fhircore.engine.R.string.cancel + }, + ), + modifier = + Modifier.padding(start = 16.dp).clickable { + if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { + onRetry() + } else { + onCancel() + } }, - ), - modifier = - Modifier.padding(start = 16.dp).clickable { - if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { - onRetry() - } else { - onCancel() - } - }, - color = MaterialTheme.colors.primary, - fontWeight = FontWeight.SemiBold, - ) + color = MaterialTheme.colors.primary, + fontWeight = FontWeight.SemiBold, + ) + } } } } From 1c565065945424a5e369e7168d6562a08eba5980 Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Wed, 11 Dec 2024 19:55:08 +0300 Subject: [PATCH 09/12] FHIR Core Enhancements (#3587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate to latest FHIR SDK Libraries * Implement CPU async foreach extension method * Refactor P2P data transer to execute in parallel * Add content to test workflow APIs * Revert Quest CI log level to Stacktrace * Update sync jobs configuration * Performance UX Improvements ⚡️ * Syncing in progress notifications * Fix crash on Android 12+ devices * Add Last sync duration info * Add Foreground Service data sync permission * Fix bug foreground service exception Android 14 --- .github/workflows/ci.yml | 6 +- android/engine/build.gradle.kts | 1 + .../configuration/ConfigurationRegistry.kt | 65 +++++----- .../app/ApplicationConfiguration.kt | 3 +- .../engine/configuration/app/ConfigService.kt | 3 + .../fhircore/engine/sync/AppSyncWorker.kt | 118 +++++++++++++++++- .../fhircore/engine/sync/CustomSyncWorker.kt | 2 +- .../engine/sync/OpenSrpDownloadManager.kt | 8 +- .../fhircore/engine/sync/SyncBroadcaster.kt | 20 ++- .../engine/sync/SyncListenerManager.kt | 9 ++ .../engine/util/NotificationConstants.kt | 33 +++++ .../fhircore/engine/util/ParallelUtil.kt | 22 ++-- .../engine/util/SharedPreferenceKey.kt | 2 + .../util/extension/ResourceExtension.kt | 74 ++++++----- .../res/drawable/ic_opensrp_small_logo.png | Bin 0 -> 1510 bytes .../engine/src/main/res/values-es/strings.xml | 4 +- .../engine/src/main/res/values-fr/strings.xml | 4 +- .../engine/src/main/res/values-in/strings.xml | 4 +- .../engine/src/main/res/values/strings.xml | 6 +- android/engine/src/main/res/values/styles.xml | 1 + .../fhircore/engine/app/AppConfigService.kt | 2 + .../ConfigurationRegistryTest.kt | 70 +++++++---- .../fhircore/engine/sync/AppSyncWorkerTest.kt | 15 ++- .../engine/task/FhirCarePlanGeneratorTest.kt | 2 +- .../fhircore/engine/util/ParallelUtilTest.kt | 8 +- .../geowidget/di/config/FakeConfigService.kt | 2 + android/gradle/libs.versions.toml | 6 +- .../fhircore/quest/integration/Faker.kt | 2 + .../ui/main/components/AppDrawerTest.kt | 2 +- .../ui/usersetting/UserSettingScreenTest.kt | 2 +- android/quest/src/main/AndroidManifest.xml | 7 +- .../fhircore/quest/QuestConfigService.kt | 2 + .../ui/appsetting/AppSettingViewModel.kt | 5 +- .../fhircore/quest/ui/login/LoginViewModel.kt | 18 ++- .../quest/ui/main/AppMainViewModel.kt | 20 +++ .../quest/ui/main/components/AppDrawer.kt | 5 +- .../quest/ui/usersetting/UserSettingScreen.kt | 2 +- .../fhircore/quest/app/AppConfigService.kt | 2 + .../quest/app/ConfigurationRegistryTest.kt | 11 +- .../ui/appsetting/AppSettingViewModelTest.kt | 55 ++++---- .../quest/ui/main/AppMainActivityTest.kt | 2 +- .../quest/ui/main/AppMainViewModelTest.kt | 6 +- 42 files changed, 450 insertions(+), 181 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/NotificationConstants.kt create mode 100644 android/engine/src/main/res/drawable/ic_opensrp_small_logo.png 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/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index 9d90fcc3d3..f26c0adfb0 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -196,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/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/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 134e1df08f..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,7 +124,7 @@ 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 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/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/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index fe502ab93e..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. 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 0000000000000000000000000000000000000000..0b490b2c67210054fc32ea0b022afe7024448bae GIT binary patch literal 1510 zcmeAS@N?(olHy`uVBq!ia0y~yVDtjw8ysvv5$*Pcz6=bkYdu{YLn`9lUQz55W@K=< zXxd@g(Zc$y!LW8j>kcnk63x^x0K#!1ugsOvqS3|-ShJ&LLc!OkJ)|C8Xng4A3J~I@w z>F+-LCGn>S>Cr_-3?sCxU3L5We6z!!U)NRC*)q;~_~Xap?`1!ygr-e7&^j8GWGCW7 zUweW`&Uc7dAuACqssHls@!MxFQ*YSsCerrar 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 e22a4b54f1..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… 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 +