diff --git a/CHANGELOG.md b/CHANGELOG.md index 458ba75bd2..427f2bae60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.1.1] - 2024-05-20 ### 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 +- Added the in-app PDF Generation feature + 1. Added a new class (PdfGenerator) for generating PDF documents from HTML content using Android's WebView and PrintManager + 2. Introduced a new class (HtmlPopulator) to populate HTML templates with data from a Questionnaire Response + 3. Implemented functionality to launch PDF generation using a configuration setup ## [1.1.0] - 2024-02-15 diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt index b4baff78b3..5367662dca 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt @@ -65,6 +65,8 @@ data class QuestionnaireConfig( val managingEntityRelationshipCode: String? = null, val uniqueIdAssignment: UniqueIdAssignmentConfig? = null, val linkIds: List? = null, + val htmlBinaryId: String? = null, + val htmlTitle: String? = null, ) : java.io.Serializable, Parcelable { fun interpolate(computedValuesMap: Map) = @@ -98,6 +100,8 @@ data class QuestionnaireConfig( uniqueIdAssignment?.copy(linkId = uniqueIdAssignment.linkId.interpolate(computedValuesMap)), linkIds = linkIds?.onEach { it.linkId.interpolate(computedValuesMap) }, saveButtonText = saveButtonText?.interpolate(computedValuesMap), + htmlBinaryId = htmlBinaryId?.interpolate(computedValuesMap), + htmlTitle = htmlTitle?.interpolate(computedValuesMap), ) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt index 296efdaffc..ea58e771f5 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt @@ -59,4 +59,7 @@ enum class ApplicationWorkflow { /** A workflow that launches location selector widget * */ LAUNCH_LOCATION_SELECTOR, + + /** A workflow to launch pdf generation */ + LAUNCH_PDF_GENERATION, } diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index b3c5187dac..cfffbaf0f4 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -186,4 +186,5 @@ Started data migration from version %1$d Application data migrated to version %1$d No data set + file diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/PdfGeneratorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/PdfGeneratorTest.kt deleted file mode 100644 index ad1282e560..0000000000 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/PdfGeneratorTest.kt +++ /dev/null @@ -1,66 +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.pdf - -import android.content.Context -import android.print.PrintDocumentAdapter -import android.print.PrintManager -import android.webkit.WebView -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.eq -import org.mockito.Mock -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import org.mockito.MockitoAnnotations -import org.mockito.junit.MockitoJUnitRunner - -@RunWith(MockitoJUnitRunner::class) -class PdfGeneratorTest { - - @Mock private lateinit var context: Context - - @Mock private lateinit var printManager: PrintManager - - @Mock private lateinit var webView: WebView - - @Mock private lateinit var printDocumentAdapter: PrintDocumentAdapter - - private lateinit var pdfGenerator: PdfGenerator - - @Before - fun setUp() { - MockitoAnnotations.initMocks(this) - `when`(context.getSystemService(Context.PRINT_SERVICE)).thenReturn(printManager) - pdfGenerator = PdfGenerator(context, webView) // Inject the mock webView - } - - @Test - fun testGeneratePdfWithHtml() { - val htmlContent = "

Hello, World!

" - val pdfTitle = "SamplePDF" - - `when`(webView.createPrintDocumentAdapter(pdfTitle)).thenReturn(printDocumentAdapter) - - pdfGenerator.generatePdfWithHtml(htmlContent, pdfTitle) - - verify(webView).loadDataWithBaseURL(null, htmlContent, "text/HTML", "UTF-8", null) - verify(webView).createPrintDocumentAdapter(pdfTitle) - verify(printManager).print(eq(pdfTitle), eq(printDocumentAdapter), eq(null)) - } -} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/PdfGenerator.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfGenerator.kt similarity index 59% rename from android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/PdfGenerator.kt rename to android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfGenerator.kt index 8d897558f2..5427aead72 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/PdfGenerator.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfGenerator.kt @@ -14,23 +14,31 @@ * limitations under the License. */ -package org.smartregister.fhircore.engine.pdf +package org.smartregister.fhircore.quest.ui.pdf import android.content.Context import android.print.PrintAttributes import android.print.PrintManager +import android.webkit.WebResourceRequest import android.webkit.WebView +import android.webkit.WebViewClient +import org.jetbrains.annotations.VisibleForTesting /** * PdfGenerator creates PDF files from HTML content using Android's WebView and PrintManager. Must * be initialized on the Main thread. * * @param context Application context for initializing WebView and PrintManager. - * @param webView Optional WebView for testing purposes. + * @param webView WebView instance for loading HTML content (Visible for testing). */ -class PdfGenerator(context: Context, private val webView: WebView = WebView(context)) { +class PdfGenerator( + private val context: Context, + @VisibleForTesting private val webView: WebView = WebView(context), +) { - private val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager + private var mWebView: WebView? = null + private val printManager: PrintManager = + context.getSystemService(Context.PRINT_SERVICE) as PrintManager /** * Generates a PDF file from the provided HTML content. @@ -47,10 +55,26 @@ class PdfGenerator(context: Context, private val webView: WebView = WebView(cont * * @param html The HTML content to be converted into a PDF. * @param pdfTitle The title of the PDF document. + * @param onPdfPrinted Callback to be invoked when the PDF is printed. */ - fun generatePdfWithHtml(html: String, pdfTitle: String) { + fun generatePdfWithHtml(html: String, pdfTitle: String, onPdfPrinted: () -> Unit) { + webView.webViewClient = + object : WebViewClient() { + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false + + override fun onPageFinished(view: WebView, url: String) { + printPdf(view, pdfTitle) + mWebView = null + onPdfPrinted.invoke() + } + } webView.loadDataWithBaseURL(null, html, "text/HTML", "UTF-8", null) - val printAdapter = webView.createPrintDocumentAdapter(pdfTitle) + mWebView = webView + } + + private fun printPdf(view: WebView, pdfTitle: String) { + val printAdapter = view.createPrintDocumentAdapter(pdfTitle) printManager.print( pdfTitle, printAdapter, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragment.kt new file mode 100644 index 0000000000..e20d72e111 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragment.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.pdf + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.hl7.fhir.r4.model.Binary +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.jetbrains.annotations.VisibleForTesting +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.pdf.HtmlPopulator +import org.smartregister.fhircore.engine.util.extension.decodeJson +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid + +/** + * A fragment for generating and displaying a PDF based on a questionnaire response. + * + * This fragment uses the provided [QuestionnaireConfig] to retrieve a questionnaire response, + * populate an HTML template with the response data, and generate a PDF. + */ +@AndroidEntryPoint +class PdfLauncherFragment : DialogFragment() { + + private val pdfLauncherViewModel by viewModels() + + @VisibleForTesting lateinit var pdfGenerator: PdfGenerator + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!this::pdfGenerator.isInitialized) pdfGenerator = PdfGenerator(requireContext()) + + val questionnaireConfig = getQuestionnaireConfig() + + val questionnaireId = questionnaireConfig.id.extractLogicalIdUuid() + val subjectId = questionnaireConfig.resourceIdentifier!!.extractLogicalIdUuid() + val subjectType = questionnaireConfig.resourceType!! + val htmlBinaryId = questionnaireConfig.htmlBinaryId!!.extractLogicalIdUuid() + val htmlTitle = questionnaireConfig.htmlTitle ?: getString(R.string.default_html_title) + + lifecycleScope.launch(Dispatchers.IO) { + val questionnaireResponse = + pdfLauncherViewModel.retrieveQuestionnaireResponse( + questionnaireId, + subjectId, + subjectType, + ) + val htmlBinary = pdfLauncherViewModel.retrieveBinary(htmlBinaryId) + generatePdf(questionnaireResponse, htmlBinary, htmlTitle) + } + } + + /** + * Retrieves and decodes the questionnaire configuration from the fragment arguments. + * + * @return the decoded [QuestionnaireConfig] object. + * @throws IllegalArgumentException if the questionnaire config is not found in arguments. + */ + private fun getQuestionnaireConfig(): QuestionnaireConfig { + val jsonConfig = + requireArguments().getString(EXTRA_QUESTIONNAIRE_CONFIG_KEY) + ?: throw IllegalArgumentException("Questionnaire config not found in arguments") + return jsonConfig.decodeJson() + } + + /** + * Generates a PDF using the provided questionnaire response and HTML template. + * + * @param questionnaireResponse the [QuestionnaireResponse] object containing user responses. + * @param htmlBinary the [Binary] object containing the HTML template. + * @param htmlTitle the title to be used for the generated PDF. + */ + private suspend fun generatePdf( + questionnaireResponse: QuestionnaireResponse?, + htmlBinary: Binary?, + htmlTitle: String, + ) { + if (questionnaireResponse == null || htmlBinary == null) { + dismiss() + return + } + + val htmlContent = htmlBinary.content.decodeToString() + val populatedHtml = HtmlPopulator(questionnaireResponse).populateHtml(htmlContent) + + withContext(Dispatchers.Main) { + pdfGenerator.generatePdfWithHtml(populatedHtml, htmlTitle) { dismiss() } + } + } + + companion object { + + /** + * Launches the PdfLauncherFragment. + * + * This method creates a new instance of PdfLauncherFragment, sets the provided questionnaire + * configuration JSON as an argument, and displays the fragment. + * + * @param appCompatActivity The activity from which the fragment is launched. + * @param questionnaireConfigJson The JSON string representing the questionnaire configuration. + */ + fun launch(appCompatActivity: AppCompatActivity, questionnaireConfigJson: String) { + PdfLauncherFragment() + .apply { arguments = bundleOf(EXTRA_QUESTIONNAIRE_CONFIG_KEY to questionnaireConfigJson) } + .show(appCompatActivity.supportFragmentManager, PdfLauncherFragment::class.java.simpleName) + } + + @VisibleForTesting const val EXTRA_QUESTIONNAIRE_CONFIG_KEY = "questionnaire_config" + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherViewModel.kt new file mode 100644 index 0000000000..b82b1488d3 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherViewModel.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.pdf + +import androidx.lifecycle.ViewModel +import com.google.android.fhir.search.Search +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import org.hl7.fhir.r4.model.Binary +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.data.local.DefaultRepository + +/** + * ViewModel for managing PDF generation related operations. + * + * This ViewModel provides methods for retrieving [QuestionnaireResponse] and [Binary] resources + * required for generating PDFs. + * + * @param defaultRepository The repository for accessing local data. + */ +@HiltViewModel +class PdfLauncherViewModel +@Inject +constructor( + val defaultRepository: DefaultRepository, +) : ViewModel() { + + /** + * Retrieve the [QuestionnaireResponse] for the given questionnaire and subject. + * + * @param questionnaireId The ID of the questionnaire. + * @param subjectId The ID of the subject. + * @param subjectType The type of the subject (resource type). + * @return The [QuestionnaireResponse] if found, otherwise null. + */ + suspend fun retrieveQuestionnaireResponse( + questionnaireId: String, + subjectId: String, + subjectType: ResourceType, + ): QuestionnaireResponse? { + val searchQuery = + createQuestionnaireResponseSearchQuery(questionnaireId, subjectId, subjectType) + return defaultRepository.search(searchQuery).firstOrNull() + } + + /** + * Create a search query for [QuestionnaireResponse]. + * + * @param questionnaireId The ID of the questionnaire. + * @param subjectId The ID of the subject. + * @param subjectType The type of the subject (resource type). + * @return The search query for [QuestionnaireResponse]. + */ + private fun createQuestionnaireResponseSearchQuery( + questionnaireId: String, + subjectId: String, + subjectType: ResourceType, + ): Search { + return Search(ResourceType.QuestionnaireResponse).apply { + filter(QuestionnaireResponse.SUBJECT, { value = "$subjectType/$subjectId" }) + filter( + QuestionnaireResponse.QUESTIONNAIRE, + { value = "${ResourceType.Questionnaire}/$questionnaireId" }, + ) + count = 1 + from = 0 + } + } + + /** + * Retrieve the [Binary] resource for the given binary ID. + * + * @param binaryId The ID of the binary resource. + * @return The [Binary] resource if found, otherwise null. + */ + suspend fun retrieveBinary(binaryId: String): Binary? { + return defaultRepository.loadResource(binaryId) + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index 6cc1d40121..58dfea4000 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -23,6 +23,7 @@ import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.core.content.ContextCompat import androidx.core.os.bundleOf @@ -59,6 +60,7 @@ import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.navigation.NavigationArg +import org.smartregister.fhircore.quest.ui.pdf.PdfLauncherFragment import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler import org.smartregister.p2p.utils.startP2PScreen import timber.log.Timber @@ -208,6 +210,12 @@ fun List.handleClickEvent( ) navController.navigate(MainNavigationScreen.LocationSelector.route, args) } + ApplicationWorkflow.LAUNCH_PDF_GENERATION -> { + val questionnaireConfig = actionConfig.questionnaire ?: return + val questionnaireConfigInterpolated = questionnaireConfig.interpolate(computedValuesMap) + val appCompatActivity = (navController.context as AppCompatActivity) + PdfLauncherFragment.launch(appCompatActivity, questionnaireConfigInterpolated.encodeJson()) + } else -> return } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/HiltTestActivity.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/HiltTestActivity.kt new file mode 100644 index 0000000000..c1cdd14961 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/HiltTestActivity.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.app.fakes + +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint class HiltTestActivity : AppCompatActivity() diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfGeneratorTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfGeneratorTest.kt new file mode 100644 index 0000000000..567704580a --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfGeneratorTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.pdf + +import android.content.Context +import android.print.PrintAttributes +import android.print.PrintDocumentAdapter +import android.print.PrintManager +import android.webkit.WebView +import android.webkit.WebViewClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class PdfGeneratorTest { + + private lateinit var pdfGenerator: PdfGenerator + private val mockContext = mockk(relaxed = true) + private val mockPrintManager = mockk(relaxed = true) + private val mockWebView = mockk(relaxed = true) + private val mockPrintDocumentAdapter = mockk(relaxed = true) + + @Before + fun setUp() { + every { mockContext.getSystemService(Context.PRINT_SERVICE) } returns mockPrintManager + every { mockWebView.createPrintDocumentAdapter(any()) } returns mockPrintDocumentAdapter + pdfGenerator = PdfGenerator(mockContext, mockWebView) + } + + @Test + fun testPdfIsPrintedWithCorrectParameters() { + val pdfTitle = "SamplePDF" + + pdfGenerator.generatePdfWithHtml("", pdfTitle) {} + + // Capture the WebViewClient that is set on the WebView + val webViewClientSlot = slot() + verify { mockWebView.webViewClient = capture(webViewClientSlot) } + + // Manually invoke the onPageFinished method to simulate page load completion + webViewClientSlot.captured.onPageFinished(mockWebView, "url") + + // Verify createPrintDocumentAdapter and printManager.print calls + verify { mockWebView.createPrintDocumentAdapter(pdfTitle) } + verify { + mockPrintManager.print( + eq(pdfTitle), + eq(mockPrintDocumentAdapter), + any(), + ) + } + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragmentTest.kt new file mode 100644 index 0000000000..d0b23bb9c6 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragmentTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.pdf + +import android.os.Bundle +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.Binary +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.ResourceType +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.robolectric.Robolectric +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.util.extension.encodeJson +import org.smartregister.fhircore.quest.app.fakes.HiltTestActivity +import org.smartregister.fhircore.quest.robolectric.RobolectricTest + +@HiltAndroidTest +class PdfLauncherFragmentTest : RobolectricTest() { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + @BindValue val pdfLauncherViewModel: PdfLauncherViewModel = mockk(relaxed = true) + + private val pdfGenerator: PdfGenerator = mockk(relaxed = true) + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun testPdfGeneration() = runBlocking { + val questionnaireResponse = QuestionnaireResponse() + val htmlBinary = Binary().apply { content = "mock content".toByteArray() } + + coEvery { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any(), any()) } returns + questionnaireResponse + coEvery { pdfLauncherViewModel.retrieveBinary(any()) } returns htmlBinary + + val questionnaireConfig = + QuestionnaireConfig( + id = "1", + resourceIdentifier = "123", + resourceType = ResourceType.Patient, + htmlBinaryId = "1234", + htmlTitle = "Title", + ) + .encodeJson() + + val fragmentArgs = + Bundle().apply { + putString(PdfLauncherFragment.EXTRA_QUESTIONNAIRE_CONFIG_KEY, questionnaireConfig) + } + + val activity = Robolectric.buildActivity(HiltTestActivity::class.java).create().resume().get() + + val fragment = + PdfLauncherFragment().apply { + arguments = fragmentArgs + pdfGenerator = this@PdfLauncherFragmentTest.pdfGenerator + } + + activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow() + + coVerify { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any(), any()) } + coVerify { pdfLauncherViewModel.retrieveBinary(any()) } + verify { pdfGenerator.generatePdfWithHtml(any(), any(), any()) } + } + + @Test + fun testPdfGenerationWhenQuestionnaireResponseIsNull() = runBlocking { + val questionnaireResponse: QuestionnaireResponse? = null + val htmlBinary = Binary().apply { content = "mock content".toByteArray() } + + coEvery { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any(), any()) } returns + questionnaireResponse + coEvery { pdfLauncherViewModel.retrieveBinary(any()) } returns htmlBinary + + val questionnaireConfig = + QuestionnaireConfig( + id = "1", + resourceIdentifier = "123", + resourceType = ResourceType.Patient, + htmlBinaryId = "1234", + htmlTitle = "Title", + ) + .encodeJson() + + val fragmentArgs = + Bundle().apply { + putString(PdfLauncherFragment.EXTRA_QUESTIONNAIRE_CONFIG_KEY, questionnaireConfig) + } + + val activity = Robolectric.buildActivity(HiltTestActivity::class.java).create().resume().get() + + val fragment = + PdfLauncherFragment().apply { + arguments = fragmentArgs + pdfGenerator = this@PdfLauncherFragmentTest.pdfGenerator + } + + activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow() + + coVerify { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any(), any()) } + coVerify { pdfLauncherViewModel.retrieveBinary(any()) } + verify(inverse = true) { pdfGenerator.generatePdfWithHtml(any(), any(), any()) } + } + + @Test + fun testPdfGenerationWhenHtmlBinaryIsNull() = runBlocking { + val questionnaireResponse = QuestionnaireResponse() + val htmlBinary: Binary? = null + + coEvery { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any(), any()) } returns + questionnaireResponse + coEvery { pdfLauncherViewModel.retrieveBinary(any()) } returns htmlBinary + + val questionnaireConfig = + QuestionnaireConfig( + id = "1", + resourceIdentifier = "123", + resourceType = ResourceType.Patient, + htmlBinaryId = "1234", + htmlTitle = "Title", + ) + .encodeJson() + + val fragmentArgs = + Bundle().apply { + putString(PdfLauncherFragment.EXTRA_QUESTIONNAIRE_CONFIG_KEY, questionnaireConfig) + } + + val activity = Robolectric.buildActivity(HiltTestActivity::class.java).create().resume().get() + + val fragment = + PdfLauncherFragment().apply { + arguments = fragmentArgs + pdfGenerator = this@PdfLauncherFragmentTest.pdfGenerator + } + + activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow() + + coVerify { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any(), any()) } + coVerify { pdfLauncherViewModel.retrieveBinary(any()) } + verify(inverse = true) { pdfGenerator.generatePdfWithHtml(any(), any(), any()) } + } + + @Test + fun testPdfGenerationWhenQuestionnaireResponseAndHtmlBinaryIsNull() = runBlocking { + val questionnaireResponse: QuestionnaireResponse? = null + val htmlBinary: Binary? = null + + coEvery { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any(), any()) } returns + questionnaireResponse + coEvery { pdfLauncherViewModel.retrieveBinary(any()) } returns htmlBinary + + val questionnaireConfig = + QuestionnaireConfig( + id = "1", + resourceIdentifier = "123", + resourceType = ResourceType.Patient, + htmlBinaryId = "1234", + htmlTitle = "Title", + ) + .encodeJson() + + val fragmentArgs = + Bundle().apply { + putString(PdfLauncherFragment.EXTRA_QUESTIONNAIRE_CONFIG_KEY, questionnaireConfig) + } + + val activity = Robolectric.buildActivity(HiltTestActivity::class.java).create().resume().get() + + val fragment = + PdfLauncherFragment().apply { + arguments = fragmentArgs + pdfGenerator = this@PdfLauncherFragmentTest.pdfGenerator + } + + activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow() + + coVerify { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any(), any()) } + coVerify { pdfLauncherViewModel.retrieveBinary(any()) } + verify(inverse = true) { pdfGenerator.generatePdfWithHtml(any(), any(), any()) } + } +} diff --git a/docs/engineering/app/configuring/pdf-generation.mdx b/docs/engineering/app/configuring/pdf-generation.mdx new file mode 100644 index 0000000000..6ce55576b3 --- /dev/null +++ b/docs/engineering/app/configuring/pdf-generation.mdx @@ -0,0 +1,105 @@ +# PDF Generation + +## Overview +The PDF generation feature using the `HtmlPopulator` class simplifies the process of dynamically populating HTML templates with data from a QuestionnaireResponse, making it easy to generate customized content based on user responses. + +The `HtmlPopulator` class is utilized by replacing custom tags with data from a QuestionnaireResponse. It supports tags such as `@is-not-empty`, `@answer-as-list`, `@answer`, `@submitted-date`, and `@contains`. + +## Usage +Below are examples of how each custom tag can be used in an HTML template and the expected output. + +### @is-not-empty + +#### Template HTML: +``` html +

@is-not-empty('linkId')This content will be included if the answer exists.@is-not-empty('linkId')

+``` + +#### Explanation: +The `@is-not-empty` tag checks if there is an answer for the specified `linkId`. If an answer exists, the content within the tags will be included in the final HTML. If no answer exists, the content will be removed. + +### @answer-as-list + +#### Template HTML: +``` html +
    + @answer-as-list('linkId') +
+``` + +#### Explanation: +The `@answer-as-list` tag will be replaced with a list of answers for the specified `linkId`. Each answer will be wrapped in an `
  • ` tag. + +### @answer + +#### Template HTML: +``` html +

    The answer is: @answer('linkId')

    +``` + +#### Explanation: +The `@answer tag` will be replaced with the answer for the specified `linkId`. If a date format is provided, the answer will be formatted accordingly. + +### @submitted-date + +#### Template HTML: +``` html +

    Submitted on: @submitted-date('MM/dd/yyyy')

    +``` + +#### Explanation: +The `@submitted-date` tag will be replaced with the formatted submission date. If no format is provided, a default date format will be used. + +### @contains + +#### Template HTML: +``` html +

    @contains('linkId', 'indicator')This content will be included if the indicator is found.@contains('linkId', 'indicator')

    +``` + +#### Explanation: +The `@contains` tag checks if the specified `linkId` contains the given `indicator`. If the indicator is found, the content within the tags will be included in the final HTML. If the indicator is not found, the content will be removed. + +## Example + +### Input HTML Template +``` html + + +

    @is-not-empty('name')Name: @answer('name')@is-not-empty('name')

    +

    Hobbies:

    +
      + @answer-as-list('hobbies') +
    +

    Submitted on: @submitted-date('yyyy-MM-dd')

    +

    @contains('age', '30')This person is 30 years old.@contains('age', '30')

    + + +``` + +### Populated HTML Output + +Assuming the QuestionnaireResponse has the following data: +
      +
    • `name`: "John Doe"
    • +
    • `hobbies`: ["Reading", "Traveling", "Cooking"]
    • +
    • `age`: 30
    • +
    • `submitted date`: "2024-07-01"
    • +
    + +The populated HTML will look like: +``` html + + +

    Name: John Doe

    +

    Hobbies:

    +
      +
    • Reading
    • +
    • Traveling
    • +
    • Cooking
    • +
    +

    Submitted on: 2024-07-01

    +

    This person is 30 years old.

    + + +``` \ No newline at end of file