From f70ad8e61e54fd4534dc00a4f2785f92f91e682a Mon Sep 17 00:00:00 2001 From: Francis Odhiambo <4540684+f-odhiambo@users.noreply.github.com> Date: Tue, 11 Jun 2024 14:02:33 +0300 Subject: [PATCH 1/3] Initial Commit (#3327) --- .../src/gizEir/res/drawable/ic_app_logo.png | Bin 1334 -> 0 bytes .../src/gizEir/res/drawable/ic_app_logo.xml | 36 ++++++++++++++++++ .../src/gizEir/res/drawable/ic_launcher.png | Bin 37891 -> 0 bytes .../src/gizEir/res/drawable/ic_launcher.xml | 16 ++++++++ 4 files changed, 52 insertions(+) delete mode 100644 android/quest/src/gizEir/res/drawable/ic_app_logo.png create mode 100644 android/quest/src/gizEir/res/drawable/ic_app_logo.xml delete mode 100644 android/quest/src/gizEir/res/drawable/ic_launcher.png create mode 100644 android/quest/src/gizEir/res/drawable/ic_launcher.xml diff --git a/android/quest/src/gizEir/res/drawable/ic_app_logo.png b/android/quest/src/gizEir/res/drawable/ic_app_logo.png deleted file mode 100644 index 16647655ca4b026ca8598f52077be7cf1537ac17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1334 zcmV-61 z+cp%(|A5pPFFF(D1fdsgl9kmH1UW&|19TLf!0rj^oFMKAWK9py=?OxgAj+z>yD)Qt zDvNe9oiWe{P^3tjqDV?a66*bC1b&F3#lIi$@bLf%ctXwo2xzl@Nil+v4e67#t(NPTSVEX3jCl`j?71j;h0~`IJRz`WVzK0yM0x~+F+JoMu`na}XF*RG;ZDD! z@3a8)q6=|%7Li0#kb@HvOOa>_pGK@EMD)}P7!g9>y(3XW>_}q~)$A>abo7FRn@~2i zR`$7zRs#|g<}#c-o}&e|MRei~zdnW5XXu4+r9_bOGW_wIS4}=tOT;+K${Ds7`h6iH zW0}^?>T=r!YJPJ{0=~iF2xtlHGpk?!`nk@!HVr6Rhzo|Ly>)@c2FQyE#Fo#Qf)$wH1buHKcrRXjmf_!5WZMCwAh^XRPXjr*1; zWi5|%n8?#IYs`tLX4fykR#-*cS}TyZ!~rH?s&UQT!l~R*+=0MU;`$46q#~->O;0)# zI%njX!5K1^s1=x+h@3U9MI4A{VCtf>2g7vFbncZGRO|lkGj9R?5VMnVi8foX?G9ShT* zBs&Yk-nl(ayCJ+)w0qVSmVvYLZUi=A@4N@t688X$xCdCoJ-{OF0Tyu&SP>&IJHeK? z8+JZnCIX^!gi`Zs121^`Gb<4#jEKOrWhJ6?Q(T!=n3afk`U9ADXgKZIJMV&wwHvzG z>wd5^|K9iWE1I6#9k8Fghc!*0@IerCNi8JC#!of z4Jky*vWIR#>LgYK-8*h$S#HMJI7J+Fv z7XPcc%1H$YdSTr%x`Z%ERTO6>#$Oyy}Xr^U>7o&ogrCuW^V_Zgj@9g}Mv#1_!pT zY@>yF8_kS#Yb(Y}0|w|POA_xF;NwL&{jT=lU90J(PD~-cr%x+<`4#ge4E4$wP#y5! zu!(pHdssIAhWZKd48}zq1a!yGynbmSCz_B{1Ho05*R4wjKj7e&)gQaxBXGi+k+;~G zJl4V@*KOqlv!iqEENvTJW{X==aqV&m_7^hr-9r-BP7@AR-y~RlrHX|;$coZkE07Jw sGE4gG2fFVq-IKNhvaEliF2vfz|5>MgL-uJr-T(jq07*qoM6N<$f}v$_MgRZ+ diff --git a/android/quest/src/gizEir/res/drawable/ic_app_logo.xml b/android/quest/src/gizEir/res/drawable/ic_app_logo.xml new file mode 100644 index 0000000000..2d6821f2da --- /dev/null +++ b/android/quest/src/gizEir/res/drawable/ic_app_logo.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/android/quest/src/gizEir/res/drawable/ic_launcher.png b/android/quest/src/gizEir/res/drawable/ic_launcher.png deleted file mode 100644 index 64cf5fb25f4ae8f582ea08d8a5fc5a1e5da731ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37891 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 J<@sBW|1XT8gG>Mb diff --git a/android/quest/src/gizEir/res/drawable/ic_launcher.xml b/android/quest/src/gizEir/res/drawable/ic_launcher.xml new file mode 100644 index 0000000000..831d0d6927 --- /dev/null +++ b/android/quest/src/gizEir/res/drawable/ic_launcher.xml @@ -0,0 +1,16 @@ + + + + + From ac0fa3d9f47d5926a4c696a8a358f409a189090e Mon Sep 17 00:00:00 2001 From: FikriMilano Date: Tue, 11 Jun 2024 21:49:08 +0700 Subject: [PATCH 2/3] QR to HTML Population (#3259) * Create HtmlPopulator class * Create some helper extensions for QR items * Allow specific date formatting on extension functions * Test HtmlPopulator class * spotlessApply * Add notes to CHANGELOG.md * Refactor HtmlPopulator - Only use 1 iteration (more efficient) - Use regex to find and match tags (easier to handle complex string patterns, and the code is more readable) - Use index for substitution (more efficient) - Use StringBuilder to avoid creating new String instances when doing substitution (using kotlin replace) - Keep using the while loop, as using forEach will skip a character following a replacement (See line 47 in HtmlPopulator class) * spotless * Add proper documentation --------- Co-authored-by: Martin Ndegwa --- CHANGELOG.md | 1 + .../fhircore/engine/pdf/HtmlPopulator.kt | 210 ++++++++ .../util/extension/DateTimeExtension.kt | 6 +- .../QuestionnaireResponseExtension.kt | 22 + .../util/extension/ResourceExtension.kt | 4 +- .../fhircore/engine/pdf/HtmlPopulatorTest.kt | 500 ++++++++++++++++++ 6 files changed, 738 insertions(+), 5 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/HtmlPopulatorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e86d322f..458ba75bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a new class (PdfGenerator) for generating PDF documents from HTML content using Android's WebView and PrintManager +- Introduced a new class (HtmlPopulator) to populate HTML templates with data from a Questionnaire Response ## [1.1.0] - 2024-02-15 diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt new file mode 100644 index 0000000000..28aaf581ed --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt @@ -0,0 +1,210 @@ +/* + * 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.pdf + +import java.util.regex.Matcher +import java.util.regex.Pattern +import org.hl7.fhir.r4.model.BaseDateTimeType +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.smartregister.fhircore.engine.util.extension.allItems +import org.smartregister.fhircore.engine.util.extension.formatDate +import org.smartregister.fhircore.engine.util.extension.makeItReadable +import org.smartregister.fhircore.engine.util.extension.valueToString + +/** + * HtmlPopulator class is responsible for processing an HTML template by replacing custom tags with + * data from a QuestionnaireResponse. The class uses various regex patterns to find and replace + * custom tags such as @is-not-empty, @answer-as-list, @answer, @submitted-date, and @contains. + * + * @property questionnaireResponse The QuestionnaireResponse object containing data for replacement. + */ +class HtmlPopulator( + private val questionnaireResponse: QuestionnaireResponse, +) { + + // Map to store questionnaire response items keyed by their linkId + private val questionnaireResponseItemMap = + questionnaireResponse.allItems.associateBy( + keySelector = { it.linkId }, + valueTransform = { it.answer }, + ) + + /** + * Populates the provided HTML template with data from the QuestionnaireResponse. + * + * After a tag got replaced, the current index will be used twice, adding an increment will skip a + * character right after the current index. + * + * @param rawHtml The raw HTML template containing custom tags to be replaced. + * @return The populated HTML with all custom tags replaced by corresponding data. + */ + fun populateHtml(rawHtml: String): String { + val html = StringBuilder(rawHtml) + var i = 0 + while (i < html.length) { + when { + html.startsWith("@is-not-empty", i) -> { + val matcher = isNotEmptyPattern.matcher(html.substring(i)) + if (matcher.find()) processIsNotEmpty(i, html, matcher) else i++ + } + html.startsWith("@answer-as-list", i) -> { + val matcher = answerAsListPattern.matcher(html.substring(i)) + if (matcher.find()) processAnswerAsList(i, html, matcher) else i++ + } + html.startsWith("@answer", i) -> { + val matcher = answerPattern.matcher(html.substring(i)) + if (matcher.find()) processAnswer(i, html, matcher) else i++ + } + html.startsWith("@submitted-date", i) -> { + val matcher = submittedDatePattern.matcher(html.substring(i)) + if (matcher.find()) processSubmittedDate(i, html, matcher) else i++ + } + html.startsWith("@contains", i) -> { + val matcher = containsPattern.matcher(html.substring(i)) + if (matcher.find()) processContains(i, html, matcher) else i++ + } + else -> i++ + } + } + return html.toString() + } + + /** + * Processes the @is-not-empty tag by checking if the specified linkId has an answer. Replaces the + * tag with the content if the answer exists, otherwise removes the tag. + * + * @param i The starting index of the tag in the HTML. + * @param html The StringBuilder containing the HTML. + * @param matcher The Matcher object for the regex pattern. + */ + private fun processIsNotEmpty(i: Int, html: StringBuilder, matcher: Matcher) { + val linkId = matcher.group(1) + val content = matcher.group(2) ?: "" + val doesAnswerExist = questionnaireResponseItemMap.getOrDefault(linkId, listOf()).isNotEmpty() + if (doesAnswerExist) { + html.replace(i, matcher.end() + i, content) + // Start index is the index of '@' symbol, End index is the index after the ')' symbol. + // For example: @is-not-empty('link')Text@is-not-empty('link') + // The args we put the the replace function: The Start index is 0, the '@' symbol. The + // End index is the index after the ')' symbol. + // Note: The ones that are going to be replaced are from the Start index which is an '@' of + // the first tag, until the index before the End index which is an ')' of the second tag. + } else { + html.replace(i, matcher.end() + i, "") + } + } + + /** + * Processes the @answer-as-list tag by replacing it with a list of answers for the specified + * linkId. + * + * @param i The starting index of the tag in the HTML. + * @param html The StringBuilder containing the HTML. + * @param matcher The Matcher object for the regex pattern. + */ + private fun processAnswerAsList(i: Int, html: StringBuilder, matcher: Matcher) { + val linkId = matcher.group(1) + val answerAsList = + questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString(separator = "") { + answer -> + "
  • ${answer.value.valueToString()}
  • " + } + html.replace(i, matcher.end() + i, answerAsList) + } + + /** + * Processes the @answer tag by replacing it with the answer for the specified linkId. + * + * @param i The starting index of the tag in the HTML. + * @param html The StringBuilder containing the HTML. + * @param matcher The Matcher object for the regex pattern. + */ + private fun processAnswer(i: Int, html: StringBuilder, matcher: Matcher) { + val linkId = matcher.group(1) + val dateFormat = matcher.group(2) + val answer = + questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString { answer -> + if (dateFormat == null) { + answer.value.valueToString() + } else answer.value.valueToString(dateFormat) + } + html.replace(i, matcher.end() + i, answer) + } + + /** + * Processes the @submitted-date tag by replacing it with the formatted date. + * + * @param i The starting index of the tag in the HTML. + * @param html The StringBuilder containing the HTML. + * @param matcher The Matcher object for the regex pattern. + */ + private fun processSubmittedDate(i: Int, html: StringBuilder, matcher: Matcher) { + val dateFormat = matcher.group(1) + val date = + if (dateFormat == null) { + questionnaireResponse.meta.lastUpdated.formatDate() + } else { + questionnaireResponse.meta.lastUpdated.formatDate(dateFormat) + } + html.replace(i, matcher.end() + i, date) + } + + /** + * Processes the @contains tag by checking if the specified linkId contains the indicator. + * Replaces the tag with the content if the indicator is found, otherwise removes the tag. + * + * @param i The starting index of the tag in the HTML. + * @param html The StringBuilder containing the HTML. + * @param matcher The Matcher object for the regex pattern. + */ + private fun processContains(i: Int, html: StringBuilder, matcher: Matcher) { + val linkId = matcher.group(1) + val indicator = matcher.group(2) ?: "" + val content = matcher.group(3) ?: "" + val doesAnswerExist = + questionnaireResponseItemMap.getOrDefault(linkId, listOf()).any { + when { + it.hasValueCoding() -> it.valueCoding.code == indicator + it.hasValueStringType() -> it.valueStringType.value.contains(indicator) + it.hasValueIntegerType() -> it.valueIntegerType.value == indicator.toInt() + it.hasValueDecimalType() -> it.valueDecimalType.value == indicator.toBigDecimal() + it.hasValueBooleanType() -> it.valueBooleanType.value == indicator.toBoolean() + it.hasValueQuantity() -> + "${it.valueQuantity.value.toPlainString()} ${it.valueQuantity.unit}" == indicator + it.hasValueDateType() || it.hasValueDateTimeType() -> + (it.value as BaseDateTimeType).value.makeItReadable() == indicator + else -> false + } + } + if (doesAnswerExist) { + html.replace(i, matcher.end() + i, content) + } else { + html.replace(i, matcher.end() + i, "") + } + } + + companion object { + // Compile regex patterns for different tags + private val isNotEmptyPattern = + Pattern.compile("@is-not-empty\\('([^']+)'\\)((?s).*?)@is-not-empty\\('\\1'\\)") + private val answerAsListPattern = Pattern.compile("@answer-as-list\\('([^']+)'\\)") + private val answerPattern = Pattern.compile("@answer\\('([^']+)'(?:,'([^']+)')?\\)") + private val submittedDatePattern = Pattern.compile("@submitted-date(?:\\('([^']+)'\\))?") + private val containsPattern = + Pattern.compile("@contains\\('([^']+)','([^']+)'\\)((?s).*?)@contains\\('\\1'\\)") + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt index ad5506e7c3..9b05fd749f 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt @@ -44,7 +44,7 @@ fun yesterday(): Date = DateTimeType.now().apply { add(Calendar.DATE, -1) }.valu fun today(): Date = DateTimeType.today().value -fun Date.formatDate(pattern: String): String = +fun Date.formatDate(pattern: String = "dd-MMM-yyyy"): String = SimpleDateFormat(pattern, Locale.ENGLISH).format(this) fun Date.isToday() = this.formatDate(SDF_YYYY_MM_DD) == today().formatDate(SDF_YYYY_MM_DD) @@ -55,11 +55,11 @@ fun SimpleDateFormat.tryParse(date: String): Date? = .runCatching { SimpleDateFormat(this.toPattern(), Locale.ENGLISH).parse(date) } .getOrNull() -fun Date?.makeItReadable(): String { +fun Date?.makeItReadable(pattern: String = "dd-MMM-yyyy"): String { return if (this == null) { "N/A" } else { - SimpleDateFormat("dd-MMM-yyyy", Locale.getDefault()).run { format(this@makeItReadable) } + SimpleDateFormat(pattern, Locale.getDefault()).run { format(this@makeItReadable) } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtension.kt index 7f606a66e6..7e4acaceec 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtension.kt @@ -33,3 +33,25 @@ private fun List.clearText() { } } } + +/** Pre-order list of all questionnaire response items in the questionnaire. */ +val QuestionnaireResponse.allItems: List + get() = item.flatMap { it.descendant } + +/** + * Pre-order list of descendants of the questionnaire response item (inclusive of the current item). + */ +val QuestionnaireResponse.QuestionnaireResponseItemComponent.descendant: + List + get() = + mutableListOf().also { + appendDescendantTo(it) + } + +private fun QuestionnaireResponse.QuestionnaireResponseItemComponent.appendDescendantTo( + output: MutableList, +) { + output.add(this) + item.forEach { it.appendDescendantTo(output) } + answer.forEach { answer -> answer.item.forEach { it.appendDescendantTo(output) } } +} 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 6ddcb2752e..b4414a662c 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 @@ -75,10 +75,10 @@ const val REFERENCE = "reference" const val PARTOF = "part-of" private val fhirR4JsonParser = FhirContext.forR4Cached().getCustomJsonParser() -fun Base?.valueToString(): String { +fun Base?.valueToString(datePattern: String = "dd-MMM-yyyy"): String { return when { this == null -> return "" - this.isDateTime -> (this as BaseDateTimeType).value.makeItReadable() + this.isDateTime -> (this as BaseDateTimeType).value.makeItReadable(datePattern) this.isPrimitive -> (this as PrimitiveType<*>).asStringValue() this is Coding -> display ?: code this is CodeableConcept -> this.stringValue() diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/HtmlPopulatorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/HtmlPopulatorTest.kt new file mode 100644 index 0000000000..2a348b4376 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/HtmlPopulatorTest.kt @@ -0,0 +1,500 @@ +/* + * 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.pdf + +import java.util.Calendar +import java.util.Date +import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.DecimalType +import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.Meta +import org.hl7.fhir.r4.model.Quantity +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent +import org.hl7.fhir.r4.model.StringType +import org.junit.Assert +import org.junit.Test + +class HtmlPopulatorTest { + + @Test + fun testIsNotEmptyShouldShowContentWhenAnswerExistInQR() { + val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

    Text

    ", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldHideContentWhenAnswerIsEmptyInQR() { + val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = emptyList() + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldHideContentWhenAnswerNotExistInQR() { + val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { addItem().apply { linkId = "link-a" } } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldHideContentWhenLinkIdNotExistInQR() { + val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-a')" + val questionnaireResponse = QuestionnaireResponse() + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldShowMalformedTagAndContentIfLinkIdOfBothTagDoesNotMatch() { + val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-b')" + val questionnaireResponse = QuestionnaireResponse() + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("@is-not-empty('link-a')

    Text

    @is-not-empty('link-b')", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldShowMalformedTagAndContentIfOnly1TagExist() { + val html = "@is-not-empty('link-a')

    Text

    " + val questionnaireResponse = QuestionnaireResponse() + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("@is-not-empty('link-a')

    Text

    ", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldShowContentAndNestedMalformedTagIfAnswerOfRootTagExist() { + val html = "@is-not-empty('link-a')@is-not-empty('link-b')

    Text

    @is-not-empty('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("@is-not-empty('link-b')

    Text

    ", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldHideContentAndNestedMalformedTagIfAnswerOfRootTagIsNotExist() { + val html = "@is-not-empty('link-a')@is-not-empty('link-b')

    Text

    @is-not-empty('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { addItem().apply { linkId = "link-a" } } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldHideContentAndNestedMalformedTagIfAnswerOfRootTagIsEmpty() { + val html = "@is-not-empty('link-a')@is-not-empty('link-b')

    Text

    @is-not-empty('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = emptyList() + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("", populatedHtml) + } + + @Test + fun testIsNotEmptyShouldShowEmptyContentIfAnswerExist() { + val html = "@is-not-empty('link-a')@is-not-empty('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("", populatedHtml) + } + + @Test + fun testProcessAnswerAsListShouldShowAnswerAsListWhenAnswerExistInQR() { + val html = "
      @answer-as-list('link-a')
    " + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 2", "code 2", "display 2") + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("
    • display 1
    • display 2
    ", populatedHtml) + } + + @Test + fun testProcessAnswerAsListShouldShowEmptyAnswerAsListWhenAnswerNotExistInQR() { + val html = "
      @answer-as-list('link-a')
    " + val questionnaireResponse = + QuestionnaireResponse().apply { addItem().apply { linkId = "link-a" } } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("
      ", populatedHtml) + } + + @Test + fun testProcessAnswerAsListShouldShowEmptyAnswerAsListWhenLinkIdNotExistInQR() { + val html = "
        @answer-as-list('link-a')
      " + val questionnaireResponse = QuestionnaireResponse() + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("
        ", populatedHtml) + } + + @Test + fun testProcessAnswerShouldShowAnswerWhenAnswerExistInQR() { + val html = "

        @answer('link-a')

        " + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = StringType("string 1") }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        string 1

        ", populatedHtml) + } + + @Test + fun testProcessAnswerShouldShowEmptyAnswerWhenAnswerNotExistInQR() { + val html = "

        @answer('link-a')

        " + val questionnaireResponse = + QuestionnaireResponse().apply { addItem().apply { linkId = "link-a" } } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        ", populatedHtml) + } + + @Test + fun testProcessAnswerShouldShowEmptyAnswerWhenLinkIdNotExistInQR() { + val html = "

        @answer('link-a')

        " + val questionnaireResponse = QuestionnaireResponse() + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        ", populatedHtml) + } + + @Test + fun testProcessAnswerShouldShowDateAnswerWhenAnswerOfTypeDateExistInQR() { + val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } + val specificDate: Date = calendar.time + val html = "

        @answer('link-a')

        " + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(specificDate) + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        14-May-2024

        ", populatedHtml) + } + + @Test + fun testProcessAnswerShouldShowDateAnswerWithFormatWhenDateFormatExistInTheTag() { + val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } + val specificDate: Date = calendar.time + val html = "

        @answer('link-a','MMMM d, yyyy')

        " + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(specificDate) + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        May 14, 2024

        ", populatedHtml) + } + + @Test + fun testProcessSubmittedDateShouldShow() { + val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } + val specificDate: Date = calendar.time + val html = "

        @submitted-date

        " + val questionnaireResponse = + QuestionnaireResponse().apply { meta = Meta().apply { lastUpdated = specificDate } } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        14-May-2024

        ", populatedHtml) + } + + @Test + fun testProcessSubmittedDateShouldShowWithFormatWhenDateFormatExistInTheTag() { + val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } + val specificDate: Date = calendar.time + val html = "

        @submitted-date('MMMM d, yyyy')

        " + val questionnaireResponse = + QuestionnaireResponse().apply { meta = Meta().apply { lastUpdated = specificDate } } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        May 14, 2024

        ", populatedHtml) + } + + @Test + fun testProcessContainsShouldShowContentWhenIndicatorCodeMatchesWithAnswerOfTypeCoding() { + val html = "@contains('link-a','code 2')

        Text

        @contains('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 2", "code 2", "display 2") + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        Text

        ", populatedHtml) + } + + @Test + fun testProcessContainsShouldHideContentWhenIndicatorCodeDoesNotMatchWithAnswerOfTypeCoding() { + val html = "@contains('link-a','code 3')

        Text

        @contains('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 2", "code 2", "display 2") + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("", populatedHtml) + } + + @Test + fun testProcessContainsShouldShowContentWhenIndicatorStringIsContainedInAnswerOfTypeString() { + val html = "@contains('link-a','basket')

        Text

        @contains('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = StringType("basketball") }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        Text

        ", populatedHtml) + } + + @Test + fun testProcessContainsShouldShowContentWhenIndicatorIntegerMatchesAnswerOfTypeInteger() { + val html = "@contains('link-a','10')

        Text

        @contains('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = IntegerType("10") }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        Text

        ", populatedHtml) + } + + @Test + fun testProcessContainsShouldShowContentWhenIndicatorDecimalMatchesAnswerOfTypeDecimal() { + val html = "@contains('link-a','1.5')

        Text

        @contains('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = DecimalType("1.5") }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        Text

        ", populatedHtml) + } + + @Test + fun testProcessContainsShouldShowContentWhenIndicatorBooleanMatchesAnswerOfTypeBoolean() { + val html = "@contains('link-a','true')

        Text

        @contains('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType("true") }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        Text

        ", populatedHtml) + } + + @Test + fun testProcessContainsShouldShowContentWhenIndicatorQuantityMatchesAnswerOfTypeQuantity() { + val html = "@contains('link-a','3 years')

        Text

        @contains('link-a')" + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Quantity(null, 3, "system", "years", "years") + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        Text

        ", populatedHtml) + } + + @Test + fun testProcessContainsShouldShowContentWhenIndicatorDateMatchesAnswerOfTypeDate() { + val html = "@contains('link-a','14-May-2024')

        Text

        @contains('link-a')" + val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } + val specificDate: Date = calendar.time + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(specificDate) + }, + ) + } + } + } + val htmlPopulator = HtmlPopulator(questionnaireResponse) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        Text

        ", populatedHtml) + } +} From 31be373539c4804e5cb3360bc87800b939fc79df Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Tue, 11 Jun 2024 17:59:20 +0300 Subject: [PATCH 3/3] Refactor code (#3318) * Refactor how FHIR URL is provided Signed-off-by: Elly Kitoto * Delete unused code Signed-off-by: Elly Kitoto * Rename configuration property Signed-off-by: Elly Kitoto * Document AppConfig property Signed-off-by: Elly Kitoto --------- Signed-off-by: Elly Kitoto --- .../fhircore/engine/OpenSrpApplication.kt | 24 ----------- .../configuration/ConfigurationRegistry.kt | 13 +++--- .../app/ApplicationConfiguration.kt | 6 +-- .../engine/configuration/app/ConfigService.kt | 2 - .../fhircore/engine/di/NetworkModule.kt | 23 +++-------- .../fhircore/engine/app/AppConfigService.kt | 4 -- .../fhircore/engine/app/fakes/Faker.kt | 13 ++---- .../ConfigurationRegistryTest.kt | 8 ---- .../data/local/DefaultRepositoryTest.kt | 24 +++++------ .../extension/QuestionnaireExtensionTest.kt | 3 +- .../geowidget/di/config/FakeConfigService.kt | 4 -- .../fhircore/quest/integration/Faker.kt | 32 ++++++++------- .../fhircore/quest/QuestApplication.kt | 9 +--- .../fhircore/quest/QuestConfigService.kt | 2 - .../quest/ui/login/ConfigDownloadWorker.kt | 7 +--- .../fhircore/quest/ui/login/LoginActivity.kt | 2 +- .../fhircore/quest/ui/login/LoginViewModel.kt | 12 ++---- .../fhircore/quest/ui/main/AppMainActivity.kt | 41 ++++++++----------- .../res/navigation/application_nav_graph.xml | 3 +- .../fhircore/quest/app/AppConfigService.kt | 2 - .../quest/app/ConfigurationRegistryTest.kt | 16 +++----- .../fhircore/quest/app/fakes/Faker.kt | 29 ++++++++----- .../configuring/config-types/application.mdx | 1 + 23 files changed, 98 insertions(+), 182 deletions(-) delete mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/OpenSrpApplication.kt diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/OpenSrpApplication.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/OpenSrpApplication.kt deleted file mode 100644 index 0eb087cece..0000000000 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/OpenSrpApplication.kt +++ /dev/null @@ -1,24 +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.engine - -import android.app.Application -import java.net.URL - -abstract class OpenSrpApplication : Application() { - abstract fun getFhirServerHost(): URL? -} 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 cf985bf633..c09dba86c9 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 @@ -52,7 +52,6 @@ import org.hl7.fhir.r4.model.ResourceType import org.jetbrains.annotations.VisibleForTesting import org.json.JSONObject import org.smartregister.fhircore.engine.BuildConfig -import org.smartregister.fhircore.engine.OpenSrpApplication import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.profile.ProfileConfiguration import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration @@ -92,18 +91,17 @@ constructor( val configService: ConfigService, val json: Json, @ApplicationContext val context: Context, - private var openSrpApplication: OpenSrpApplication?, ) { + @Inject lateinit var knowledgeManager: KnowledgeManager + val configsJsonMap = mutableMapOf() val configCacheMap = mutableMapOf() val localizationHelper: LocalizationHelper by lazy { LocalizationHelper(this) } private val supportedFileExtensions = listOf("json", "properties") private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK private val fhirContext = FhirContext.forR4Cached() - - @Inject lateinit var knowledgeManager: KnowledgeManager - + private val authConfiguration = configService.provideAuthConfiguration() private val jsonParser = fhirContext.newJsonParser() /** @@ -405,8 +403,7 @@ constructor( * Type'?_id='comma,separated,list,of,ids' */ @Throws(UnknownHostException::class, HttpException::class) - suspend fun fetchNonWorkflowConfigResources(isInitialLogin: Boolean = true) { - // Reset configurations before loading new ones + suspend fun fetchNonWorkflowConfigResources() { configCacheMap.clear() sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)?.let { appId -> val parsedAppId = appId.substringBefore(TYPE_REFERENCE_DELIMITER).trim() @@ -638,7 +635,7 @@ constructor( this.apply { url = url - ?: """${openSrpApplication?.getFhirServerHost()?.toString()?.trimEnd { it == '/' }}/${this.referenceValue()}""" + ?: """${authConfiguration.fhirServerBaseUrl.trimEnd { it == '/' }}/${this.referenceValue()}""" } fun writeToFile(resource: Resource): File { 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 e4dfa850d1..c9d72c48b0 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 @@ -50,10 +50,8 @@ data class ApplicationConfiguration( SettingsOptions.INSIGHTS, ), val logGpsLocation: List = emptyList(), - val usePractitionerAssignedLocationOnSync: Boolean = - true, // TODO This defaults to scheduling periodic sync, otherwise use sync location ids from - // location selector - val launcherType: LauncherType = LauncherType.REGISTER, + val usePractitionerAssignedLocationOnSync: Boolean = true, + val navigationStartDestination: LauncherType = LauncherType.REGISTER, ) : Configuration() enum class SyncStrategy { 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 db74403edc..9f238c46e7 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 @@ -70,8 +70,6 @@ interface ConfigService { return tags } - fun provideConfigurationSyncPageSize(): String - /** * Provide a list of custom search parameters. * 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 9a3e0918fe..e2c346fc93 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 @@ -16,7 +16,6 @@ package org.smartregister.fhircore.engine.di -import android.content.Context import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.IParser import com.google.gson.Gson @@ -25,8 +24,8 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.net.URL import java.util.TimeZone import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -40,7 +39,6 @@ import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.logging.HttpLoggingInterceptor import org.smartregister.fhircore.engine.BuildConfig -import org.smartregister.fhircore.engine.OpenSrpApplication import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService import org.smartregister.fhircore.engine.data.remote.auth.OAuthService @@ -83,7 +81,7 @@ class NetworkModule { fun provideOkHttpClient( tokenAuthenticator: TokenAuthenticator, sharedPreferencesHelper: SharedPreferencesHelper, - openSrpApplication: OpenSrpApplication?, + configService: ConfigService, ) = OkHttpClient.Builder() .addInterceptor( @@ -92,15 +90,11 @@ class NetworkModule { var request = chain.request() val requestPath = request.url.encodedPath.substring(1) val resourcePath = if (!_isNonProxy) requestPath.replace("fhir/", "") else requestPath + val host = URL(configService.provideAuthConfiguration().fhirServerBaseUrl).host - openSrpApplication?.let { - if ( - (request.url.host == it.getFhirServerHost()?.host) && - CUSTOM_ENDPOINTS.contains(resourcePath) - ) { - val newUrl = request.url.newBuilder().encodedPath("/$resourcePath").build() - request = request.newBuilder().url(newUrl).build() - } + if (request.url.host == host && CUSTOM_ENDPOINTS.contains(resourcePath)) { + val newUrl = request.url.newBuilder().encodedPath("/$resourcePath").build() + request = request.newBuilder().url(newUrl).build() } chain.proceed(request) @@ -231,11 +225,6 @@ class NetworkModule { fun provideFhirResourceService(@RegularRetrofit retrofit: Retrofit): FhirResourceService = retrofit.create(FhirResourceService::class.java) - @Provides - @Singleton - fun provideFHIRBaseURL(@ApplicationContext context: Context): OpenSrpApplication? = - if (context is OpenSrpApplication) context else null - companion object { const val TIMEOUT_DURATION = 120L const val AUTHORIZATION = "Authorization" diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt index d2944ac3e4..b67d16ff93 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt @@ -71,10 +71,6 @@ class AppConfigService @Inject constructor(@ApplicationContext val context: Cont ), ) - override fun provideConfigurationSyncPageSize(): String { - return "100" - } - companion object { const val CARETEAM_SYSTEM = "http://fake.tag.com/CareTeam#system" const val CARETEAM_DISPLAY = "Practitioner CareTeam" diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/Faker.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/Faker.kt index cd835dcfed..398f98976a 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/Faker.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/Faker.kt @@ -24,7 +24,6 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.spyk -import java.net.URL import java.util.Calendar import java.util.Date import kotlinx.coroutines.runBlocking @@ -38,7 +37,7 @@ import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.StringType -import org.smartregister.fhircore.engine.OpenSrpApplication +import org.smartregister.fhircore.engine.app.AppConfigService import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService @@ -57,6 +56,8 @@ object Faker { } private val testDispatcher = UnconfinedTestDispatcher() + private val configService = + AppConfigService(ApplicationProvider.getApplicationContext()) private val testDispatcherProvider = object : DispatcherProvider { @@ -98,15 +99,9 @@ object Faker { fhirResourceDataSource = fhirResourceDataSource, sharedPreferencesHelper = sharedPreferencesHelper, dispatcherProvider = dispatcherProvider, - configService = mockk(), + configService = configService, json = json, context = ApplicationProvider.getApplicationContext(), - openSrpApplication = - object : OpenSrpApplication() { - override fun getFhirServerHost(): URL { - return URL("http://my_test_fhirbase_url/fhir/") - } - }, ), ) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt index 0c4d080bed..9a74923aa6 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt @@ -33,7 +33,6 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import io.mockk.spyk -import java.net.URL import javax.inject.Inject import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -59,7 +58,6 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test -import org.smartregister.fhircore.engine.OpenSrpApplication import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry.Companion.MANIFEST_PROCESSOR_BATCH_SIZE import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry.Companion.PAGINATION_NEXT @@ -115,12 +113,6 @@ class ConfigurationRegistryTest : RobolectricTest() { configService = configService, json = json, context = ApplicationProvider.getApplicationContext(), - openSrpApplication = - object : OpenSrpApplication() { - override fun getFhirServerHost(): URL { - return URL("http://my_test_fhirbase_url/fhir/") - } - }, ) } 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 c30cc2ffdc..c2bc83fb1e 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 @@ -26,6 +26,7 @@ import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get import com.google.android.fhir.logicalId import com.google.gson.Gson +import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.coEvery @@ -72,6 +73,7 @@ import org.junit.Assert import org.junit.Before import org.junit.Rule 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.app.ConfigService @@ -111,31 +113,31 @@ class DefaultRepositoryTest : RobolectricTest() { @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor - @Inject lateinit var configService: ConfigService - @Inject lateinit var fhirEngine: FhirEngine @Inject lateinit var parser: IParser + + @BindValue + val configService: ConfigService = + spyk(AppConfigService(ApplicationProvider.getApplicationContext())) private val application = ApplicationProvider.getApplicationContext() private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private lateinit var dispatcherProvider: DefaultDispatcherProvider private lateinit var sharedPreferenceHelper: SharedPreferencesHelper private lateinit var defaultRepository: DefaultRepository - private lateinit var spiedConfigService: ConfigService @Before fun setUp() { hiltRule.inject() dispatcherProvider = DefaultDispatcherProvider() sharedPreferenceHelper = SharedPreferencesHelper(application, gson) - spiedConfigService = spyk(configService) defaultRepository = DefaultRepository( fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider, sharedPreferencesHelper = sharedPreferenceHelper, configurationRegistry = configurationRegistry, - configService = spiedConfigService, + configService = configService, configRulesExecutor = configRulesExecutor, fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, @@ -313,27 +315,25 @@ class DefaultRepositoryTest : RobolectricTest() { @Test fun testCreateShouldNotDuplicateMetaTagsWithSameSystemCode() { val system = "https://smartregister.org/location-tag-id" - val code = "86453" - val anotherCode = "10200" - val coding = Coding(system, code, "Location") - val anotherCoding = Coding(system, anotherCode, "Location") + val coding = Coding(system, "86453", "Location") + val anotherCoding = Coding(system, "10200", "Location") val resource = Patient().apply { meta.addTag(coding) } // Meta contains 1 tag with code 86453 Assert.assertEquals(1, resource.meta.tag.size) val firstTag = resource.meta.tag.first() - Assert.assertEquals(code, firstTag.code) + Assert.assertEquals("86453", firstTag.code) Assert.assertEquals(system, firstTag.system) coEvery { fhirEngine.create(any()) } returns listOf(resource.id) - every { spiedConfigService.provideResourceTags(sharedPreferenceHelper) } returns + every { configService.provideResourceTags(sharedPreferenceHelper) } returns listOf(coding, anotherCoding) runBlocking { defaultRepository.create(true, resource) } // Expecting 2 tags; tag with code 86453 should not be duplicated. Assert.assertEquals(2, resource.meta.tag.size) Assert.assertNotNull(resource.meta.lastUpdated) - Assert.assertNotNull(resource.meta.getTag(system, code)) + Assert.assertNotNull(resource.meta.getTag(system, "86453")) } @Test diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt index 00de68dd1d..37d703c4a4 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt @@ -37,8 +37,9 @@ import org.junit.Test import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType +import org.smartregister.fhircore.engine.robolectric.RobolectricTest -class QuestionnaireExtensionTest { +class QuestionnaireExtensionTest : RobolectricTest() { private lateinit var questionniare: Questionnaire private lateinit var questionniareResponse: QuestionnaireResponse private lateinit var questionniareResponseItemComponent: diff --git a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/di/config/FakeConfigService.kt b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/di/config/FakeConfigService.kt index 5ca688e12f..13479c4bb0 100644 --- a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/di/config/FakeConfigService.kt +++ b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/di/config/FakeConfigService.kt @@ -70,10 +70,6 @@ class FakeConfigService @Inject constructor() : ConfigService { ), ) - override fun provideConfigurationSyncPageSize(): String { - return "100" - } - companion object { const val CARETEAM_SYSTEM = "http://fake.tag.com/CareTeam#system" const val CARETEAM_DISPLAY = "Practitioner CareTeam" diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt index e052c0efcf..a110197a3b 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.quest.integration import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.FhirEngine import com.google.android.fhir.LocalChange import com.google.android.fhir.SearchResult @@ -27,7 +28,6 @@ import com.google.android.fhir.sync.upload.SyncUploadProgress import com.google.android.fhir.sync.upload.UploadRequestResult import com.google.gson.Gson import dagger.hilt.android.testing.HiltTestApplication -import java.net.URL import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -36,12 +36,12 @@ import kotlinx.serialization.json.Json import okhttp3.RequestBody import okhttp3.ResponseBody import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.OperationOutcome import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType -import org.smartregister.fhircore.engine.OpenSrpApplication import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.AuthConfiguration import org.smartregister.fhircore.engine.configuration.app.ConfigService @@ -161,15 +161,25 @@ object Faker { val configService = object : ConfigService { override fun provideAuthConfiguration(): AuthConfiguration { - TODO("Not yet implemented") + return AuthConfiguration( + fhirServerBaseUrl = "http://fake.base.url.com", + oauthServerBaseUrl = "http://fake.keycloak.url.com", + clientId = "fake-client-id", + accountType = InstrumentationRegistry.getInstrumentation().context.packageName, + ) } override fun defineResourceTags(): List { - TODO("Not yet implemented") - } - - override fun provideConfigurationSyncPageSize(): String { - TODO("Not yet implemented") + return listOf( + ResourceTag( + type = ResourceType.Location.name, + tag = + Coding().apply { + system = "http://fake.tag.com/Location#system" + display = "Practitioner Location" + }, + ), + ) } } @@ -190,12 +200,6 @@ object Faker { dispatcherProvider = DefaultDispatcherProvider(), json = json, context = ApplicationProvider.getApplicationContext(), - openSrpApplication = - object : OpenSrpApplication() { - override fun getFhirServerHost(): URL? { - return URL("http://my_test_fhirbase_url/fhir/") - } - }, ) runBlocking { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt index 3089af3d5f..0945b15c5b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest +import android.app.Application import android.database.CursorWindow import android.util.Log import androidx.annotation.VisibleForTesting @@ -32,7 +33,6 @@ import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.fragment.FragmentLifecycleIntegration import java.net.URL import javax.inject.Inject -import org.smartregister.fhircore.engine.OpenSrpApplication import org.smartregister.fhircore.engine.data.remote.fhir.resource.ReferenceUrlResolver import org.smartregister.fhircore.engine.util.extension.getSubDomain import org.smartregister.fhircore.quest.data.QuestXFhirQueryResolver @@ -40,7 +40,7 @@ import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireItemViewHo import timber.log.Timber @HiltAndroidApp -class QuestApplication : OpenSrpApplication(), DataCaptureConfig.Provider, Configuration.Provider { +class QuestApplication : Application(), DataCaptureConfig.Provider, Configuration.Provider { @EntryPoint @InstallIn(SingletonComponent::class) interface HiltWorkerFactoryEntryPoint { @@ -125,9 +125,4 @@ class QuestApplication : OpenSrpApplication(), DataCaptureConfig.Provider, Confi EntryPoints.get(this, HiltWorkerFactoryEntryPoint::class.java).workerFactory(), ) .build() - - override fun getFhirServerHost(): URL? { - fhirServerHost = fhirServerHost ?: URL(BuildConfig.FHIR_BASE_URL) - return fhirServerHost - } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt index f0daa199ad..217942fd43 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt @@ -107,6 +107,4 @@ class QuestConfigService @Inject constructor(@ApplicationContext val context: Co }, ), ) - - override fun provideConfigurationSyncPageSize(): String = BuildConfig.CONFIGURATION_SYNC_PAGE_SIZE } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/ConfigDownloadWorker.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/ConfigDownloadWorker.kt index f3b3495b18..468198f0f6 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/ConfigDownloadWorker.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/ConfigDownloadWorker.kt @@ -43,10 +43,9 @@ constructor( ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { - val isInitialLogin = inputData.getBoolean(IS_INITIAL_LOGIN, true) return withContext(dispatcherProvider.io()) { try { - configurationRegistry.fetchNonWorkflowConfigResources(isInitialLogin) + configurationRegistry.fetchNonWorkflowConfigResources() dataMigration.migrate() Result.success() } catch (httpException: HttpException) { @@ -56,8 +55,4 @@ constructor( } } } - - companion object { - const val IS_INITIAL_LOGIN = "isInitialLogin" - } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt index e0b6ec306c..367766311d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt @@ -81,7 +81,7 @@ open class LoginActivity : BaseMultiLanguageActivity() { navigateToHome.observe(loginActivity) { launchHomeScreen -> if (launchHomeScreen) { - downloadNowWorkflowConfigs(isInitialLogin = false) + downloadNowWorkflowConfigs() if (isPinEnabled && !hasActivePin) { navigateToPinLogin(launchSetup = true) } else loginActivity.navigateToHome() diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt index 4fda6dd085..45811d2364 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt @@ -23,10 +23,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.Constraints import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import dagger.hilt.android.lifecycle.HiltViewModel import io.sentry.Sentry import io.sentry.protocol.User @@ -458,15 +456,13 @@ constructor( ) } - fun downloadNowWorkflowConfigs(isInitialLogin: Boolean = true) { - val data = workDataOf(ConfigDownloadWorker.IS_INITIAL_LOGIN to isInitialLogin) - val oneTimeWorkRequest: OneTimeWorkRequest = + fun downloadNowWorkflowConfigs() { + workManager.enqueue( OneTimeWorkRequestBuilder() .setConstraints( Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), ) - .setInputData(data) - .build() - workManager.enqueue(oneTimeWorkRequest) + .build(), + ) } } 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 c5a44bd8a7..19fe388075 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 @@ -110,8 +110,8 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, setupLocationServices() setContentView(FragmentContainerView(this).apply { id = R.id.nav_host }) val topMenuConfig = appMainViewModel.navigationConfiguration.clientRegisters.first() - val topMenuConfigId = - topMenuConfig.actions?.find { it.trigger == ActionTrigger.ON_CLICK }?.id ?: topMenuConfig.id + val clickAction = topMenuConfig.actions?.find { it.trigger == ActionTrigger.ON_CLICK } + val topMenuConfigId = clickAction?.id ?: topMenuConfig.id navHostFragment = NavHostFragment.create( R.navigation.application_nav_graph, @@ -161,9 +161,20 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, override fun onResume() { super.onResume() - navHostFragment.navController.addOnDestinationChangedListener(sentryNavListener) - syncListenerManager.registerSyncListener(this, lifecycle) - setStartDestination() + // Create NavController after fragment has been attached + navHostFragment.apply { + val graph = + navController.navInflater.inflate(R.navigation.application_nav_graph).apply { + val startDestination = + when (appMainViewModel.applicationConfiguration.navigationStartDestination) { + LauncherType.MAP -> R.id.geoWidgetLauncherFragment + else -> R.id.registerFragment + } + setStartDestination(startDestination) + } + navController.addOnDestinationChangedListener(sentryNavListener) + navController.graph = graph + } } override fun onPause() { @@ -171,26 +182,6 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, navHostFragment.navController.removeOnDestinationChangedListener(sentryNavListener) } - private fun setStartDestination() { - val navController = navHostFragment.navController - val startDestination = - when (appMainViewModel.applicationConfiguration.launcherType) { - LauncherType.MAP -> { - R.id.geoWidgetLauncherFragment - } - else -> { - R.id.registerFragment - } - } - // Inflate the navigation graph - val navInflater = navController.navInflater - val graph = navInflater.inflate(R.navigation.application_nav_graph) - // Set the start destination - graph.setStartDestination(startDestination) - // Set the modified NavGraph to the NavController - navController.graph = graph - } - override suspend fun onSubmitQuestionnaire(activityResult: ActivityResult) { if (activityResult.resultCode == RESULT_OK) { val questionnaireResponse: QuestionnaireResponse? = 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 95bdbf3632..f9a74d85a6 100644 --- a/android/quest/src/main/res/navigation/application_nav_graph.xml +++ b/android/quest/src/main/res/navigation/application_nav_graph.xml @@ -10,8 +10,7 @@ + app:nullable="true"/> { coEvery { post(any(), any()) } returns Bundle() } private val fhirResourceDataSource = spyk(FhirResourceDataSource(fhirResourceService)) - @Inject lateinit var dispatcherProvider: DispatcherProvider - @Before @kotlinx.coroutines.ExperimentalCoroutinesApi fun setUp() { @@ -83,15 +83,9 @@ class ConfigurationRegistryTest : RobolectricTest() { fhirResourceDataSource = fhirResourceDataSource, sharedPreferencesHelper = sharedPreferencesHelper, dispatcherProvider = dispatcherProvider, - configService = mockk(), + configService = configService, json = Faker.json, context = ApplicationProvider.getApplicationContext(), - openSrpApplication = - object : OpenSrpApplication() { - override fun getFhirServerHost(): URL? { - return URL("http://my_test_fhirbase_url/fhir/") - } - }, ), ) configurationRegistry.setNonProxy(false) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/Faker.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/Faker.kt index cde9695e13..3e5865b8c5 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/Faker.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/Faker.kt @@ -25,10 +25,10 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.spyk -import java.net.URL import java.util.Calendar import java.util.Date import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.serialization.json.Json import org.hl7.fhir.r4.model.Basic import org.hl7.fhir.r4.model.Binary @@ -37,12 +37,13 @@ import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.StringType -import org.smartregister.fhircore.engine.OpenSrpApplication import org.smartregister.fhircore.engine.auth.AuthCredentials import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService +import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.quest.app.AppConfigService import org.smartregister.fhircore.quest.ui.login.LoginActivity object Faker { @@ -59,13 +60,25 @@ object Faker { private const val APP_DEBUG = "app/debug" - val sampleImageJSONString = + private val sampleImageJSONString = "{\n" + " \"id\": \"d60ff460-7671-466a-93f4-c93a2ebf2077\",\n" + " \"resourceType\": \"Binary\",\n" + " \"contentType\": \"image/jpeg\",\n" + " \"data\": \"iVBORw0KGgoAAAANSUhEUgAAAFMAAABTCAYAAADjsjsAAAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAAAtdEVYdENyZWF0aW9uIFRpbWUARnJpIDE5IEFwciAyMDI0IDA3OjIxOjM4IEFNIEVBVIqENmYAAADTSURBVHic7dDBCcAgAMBAdf/p+nQZXSIglLsJQube3xkk1uuAPzEzZGbIzJCZITNDZobMDJkZMjNkZsjMkJkhM0NmhswMmRkyM2RmyMyQmSEzQ2aGzAyZGTIzZGbIzJCZITNDZobMDJkZMjNkZsjMkJkhM0NmhswMmRkyM2RmyMyQmSEzQ2aGzAyZGTIzZGbIzJCZITNDZobMDJkZMjNkZsjMkJkhM0NmhswMmRkyM2RmyMyQmSEzQ2aGzAyZGTIzZGbIzJCZITNDZobMDJkZMjN0AXiwBCviCqIRAAAAAElFTkSuQmCC\"\n" + "}" + private val testDispatcher = UnconfinedTestDispatcher() + private val configService = AppConfigService(ApplicationProvider.getApplicationContext()) + private val testDispatcherProvider = + object : DispatcherProvider { + override fun default() = testDispatcher + + override fun io() = testDispatcher + + override fun main() = testDispatcher + + override fun unconfined() = testDispatcher + } fun buildTestConfigurationRegistry(): ConfigurationRegistry { val fhirResourceService = mockk() @@ -78,16 +91,10 @@ object Faker { fhirEngine = mockk(), fhirResourceDataSource = fhirResourceDataSource, sharedPreferencesHelper = mockk(), - dispatcherProvider = mockk(), - configService = mockk(), + dispatcherProvider = testDispatcherProvider, + configService = configService, json = json, context = ApplicationProvider.getApplicationContext(), - openSrpApplication = - object : OpenSrpApplication() { - override fun getFhirServerHost(): URL? { - return URL("http://my_test_fhirbase_url/fhir/") - } - }, ), ) diff --git a/docs/engineering/app/configuring/config-types/application.mdx b/docs/engineering/app/configuring/config-types/application.mdx index dea00db12d..f20f831d2a 100644 --- a/docs/engineering/app/configuring/config-types/application.mdx +++ b/docs/engineering/app/configuring/config-types/application.mdx @@ -87,3 +87,4 @@ The `logGpsLocation` config takes in a list of `LocationLogOptions` to toggle wh `settingsScreenMenuOptions` | A list of `SettingsOptions`s that defines menu options to be displayed on the `Settings` screen | Yes | `listOf(SettingsOptions.MANUAL_SYNC, SettingsOptions.SWITCH_LANGUAGES, SettingsOptions.RESET_DATA, SettingsOptions.INSIGHTS,)` | `logGpsLocation` | A list of `LocationLogOptions` to toggle whether to capture GPS coordinates | Yes | emptyList() | `usePractitionerAssignedLocationOnSync` | If `true`, default to using logged in practitioner's location | Yes | `true` | +`navigationStartDestination` | Set the defualt start destination for the navigation graph (supported options includes: REGISTER and MAP) | Yes | 'REGISTER` |