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/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/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/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 881d049901..2611105857 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 dagger.hilt.android.testing.HiltTestApplication @@ -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,32 +113,32 @@ 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 val context = ApplicationProvider.getApplicationContext() private lateinit var dispatcherProvider: DefaultDispatcherProvider private lateinit var sharedPreferenceHelper: SharedPreferencesHelper private lateinit var defaultRepository: DefaultRepository - private lateinit var spiedConfigService: ConfigService - private val context = ApplicationProvider.getApplicationContext() @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, @@ -291,27 +293,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/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) + } +} 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/gizEir/res/drawable/ic_app_logo.png b/android/quest/src/gizEir/res/drawable/ic_app_logo.png deleted file mode 100644 index 16647655ca..0000000000 Binary files a/android/quest/src/gizEir/res/drawable/ic_app_logo.png and /dev/null differ 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 64cf5fb25f..0000000000 Binary files a/android/quest/src/gizEir/res/drawable/ic_launcher.png and /dev/null differ 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 @@ + + + + + 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 c4d908d47e..f7b1b91067 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 @@ -111,8 +111,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, @@ -160,9 +160,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() { @@ -170,26 +181,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` |