From 0299b9275358e8f2f18baa908f6581ebcaf999f5 Mon Sep 17 00:00:00 2001 From: Andre Destro Date: Tue, 12 Aug 2025 16:38:39 +0100 Subject: [PATCH 1/6] feat: add support for pdf --- .gitignore | 5 +- build.gradle | 84 +++++++++++- .../views/OSIABWebViewActivity.kt | 123 +++++++++++++++--- 3 files changed, 192 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 7007eb6..c754c6d 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,7 @@ lint/tmp/ .DS_Store Gemfile.lock -scripts/tmp \ No newline at end of file +scripts/tmp + +# PDF.js files +src/main/assets/pdfjs/ diff --git a/build.gradle b/build.gradle index d6de6e9..f0dde17 100644 --- a/build.gradle +++ b/build.gradle @@ -141,4 +141,86 @@ dependencies { if (System.getenv("SHOULD_PUBLISH") == "true") { apply from: file("./scripts/publish-module.gradle") -} \ No newline at end of file +} + +import java.net.URL + +def pdfJsVersion = "5.4.54" +def pdfJsUrl = "https://github.com/mozilla/pdf.js/releases/download/v${pdfJsVersion}/pdfjs-${pdfJsVersion}-dist.zip" +def pdfJsDestDir = "$projectDir/src/main/assets/pdfjs" +def pdfJsCacheDir = new File(gradle.gradleUserHomeDir, "pdfjs-cache/${pdfJsVersion}") + +tasks.register("downloadPdfJs") { + description = "Downloads and caches PDF.js distribution files" + group = "build setup" + + // Define inputs/outputs for up-to-date checking + inputs.property("pdfJsVersion", pdfJsVersion) + outputs.dir(pdfJsDestDir) + + doLast { + def destDir = file(pdfJsDestDir) + + // Check if destination already has files + if (destDir.exists() && destDir.listFiles()?.size() > 0) { + logger.info("PDF.js files already exist in ${destDir}") + return + } + + // Check if cache has files and copy them + if (pdfJsCacheDir.exists() && pdfJsCacheDir.listFiles()?.size() > 0) { + logger.info("Copying PDF.js from cache: ${pdfJsCacheDir}") + copy { + from pdfJsCacheDir + into destDir + } + return + } + + // Download PDF.js + logger.info("Downloading PDF.js v${pdfJsVersion} from ${pdfJsUrl}") + def zipFile = file("$buildDir/pdfjs-${pdfJsVersion}.zip") + + try { + // Ensure build directory exists + zipFile.parentFile.mkdirs() + + // Download with better error handling + new URL(pdfJsUrl).withInputStream { inputStream -> + zipFile.withOutputStream { outputStream -> + outputStream << inputStream + } + } + + logger.info("Downloaded PDF.js to: ${zipFile}") + + // Extract to cache + pdfJsCacheDir.mkdirs() + copy { + from zipTree(zipFile) + into pdfJsCacheDir + } + + // Copy from cache to destination + if (destDir.exists()) { + delete destDir + } + destDir.mkdirs() + copy { + from pdfJsCacheDir + into destDir + } + + logger.info("PDF.js extracted to: ${destDir}") + + // Clean up downloaded zip + delete zipFile + + } catch (Exception e) { + logger.error("Failed to download PDF.js: ${e.message}") + throw new GradleException("Could not download PDF.js from ${pdfJsUrl}", e) + } + } +} + +preBuild.dependsOn(downloadPdfJs) diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt index 95076aa..5d7461a 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt @@ -4,12 +4,12 @@ import android.Manifest import android.app.Activity import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.os.Bundle -import android.view.Gravity import android.util.Log -import android.graphics.Bitmap +import android.view.Gravity import android.view.View import android.webkit.CookieManager import android.webkit.GeolocationPermissions @@ -40,7 +40,13 @@ import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents.OSIABWe import com.outsystems.plugins.inappbrowser.osinappbrowserlib.R import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABToolbarPosition import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABWebViewOptions +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL class OSIABWebViewActivity : AppCompatActivity() { @@ -88,6 +94,11 @@ class OSIABWebViewActivity : AppCompatActivity() { // for back navigation private lateinit var onBackPressedCallback: OnBackPressedCallback + private val PDF_VIEWER_URL_PREFIX = "file:///android_asset/pdfjs/web/viewer.html?file=" + // the original URL of the PDF file, used to display it correctly in the view + // and to send the correct URL in the browserPageNavigationCompleted event + private var originalUrl: String? = null + companion object { const val WEB_VIEW_URL_EXTRA = "WEB_VIEW_URL_EXTRA" const val WEB_VIEW_OPTIONS_EXTRA = "WEB_VIEW_OPTIONS_EXTRA" @@ -173,7 +184,7 @@ class OSIABWebViewActivity : AppCompatActivity() { setupWebView() if (urlToOpen != null) { - webView.loadUrl(urlToOpen, customHeaders ?: emptyMap()) + handleLoadUrl(urlToOpen, customHeaders) showLoadingScreen() } @@ -206,6 +217,71 @@ class OSIABWebViewActivity : AppCompatActivity() { } } + private fun handleLoadUrl(url: String, additionalHttpHeaders: Map? = null) { + lifecycleScope.launch(Dispatchers.IO) { + if (isContentTypeApplicationPdf(url)) { + val pdfFile = try { downloadPdfToCache(url) } catch (_: IOException) { null } + if (pdfFile != null) { + withContext(Dispatchers.Main) { + webView.stopLoading() + originalUrl = url + val pdfJsUrl = + PDF_VIEWER_URL_PREFIX + Uri.encode("file://${pdfFile.absolutePath}") + webView.loadUrl(pdfJsUrl) + } + return@launch + } + } + + withContext(Dispatchers.Main) { + webView.loadUrl(url, additionalHttpHeaders ?: emptyMap()) + } + } + } + + fun isContentTypeApplicationPdf(urlString: String): Boolean { + return try { + if (checkPdfByRequest(urlString, method = "HEAD")) { + true + } else { + checkPdfByRequest(urlString, method = "GET") + } + } catch (_: Exception) { + false + } + } + + private fun checkPdfByRequest(urlString: String, method: String): Boolean { + var conn: HttpURLConnection? = null + return try { + conn = (URL(urlString).openConnection() as? HttpURLConnection) + conn?.run { + instanceFollowRedirects = true + requestMethod = method + if (method == "GET") { + setRequestProperty("Range", "bytes=0-0") + } + connect() + val type = contentType?.lowercase() + val disposition = getHeaderField("Content-Disposition")?.lowercase() + type == "application/pdf" || + (type.isNullOrEmpty() && disposition?.contains(".pdf") == true) + } ?: false + } finally { + conn?.disconnect() + } + } + + private fun downloadPdfToCache(url: String): File { + val pdfFile = File(cacheDir, "temp_${System.currentTimeMillis()}.pdf") + URL(url).openStream().use { input -> + pdfFile.outputStream().use { output -> + input.copyTo(output) + } + } + return pdfFile + } + /** * Helper function to update navigation button states */ @@ -232,19 +308,24 @@ class OSIABWebViewActivity : AppCompatActivity() { * It also deals with URLs that are opened withing the WebView. */ private fun setupWebView() { - webView.settings.javaScriptEnabled = true - webView.settings.javaScriptCanOpenWindowsAutomatically = true - webView.settings.databaseEnabled = true - webView.settings.domStorageEnabled = true - webView.settings.loadWithOverviewMode = true - webView.settings.useWideViewPort = true + webView.settings.apply { + javaScriptEnabled = true + javaScriptCanOpenWindowsAutomatically = true + databaseEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + allowFileAccess = true + allowFileAccessFromFileURLs = true + allowUniversalAccessFromFileURLs = true - if (!options.customUserAgent.isNullOrEmpty()) - webView.settings.userAgentString = options.customUserAgent + if (!options.customUserAgent.isNullOrEmpty()) + userAgentString = options.customUserAgent - // get webView settings that come from options - webView.settings.builtInZoomControls = options.allowZoom - webView.settings.mediaPlaybackRequiresUserGesture = options.mediaPlaybackRequiresUserAction + // get webView settings that come from options + builtInZoomControls = options.allowZoom + mediaPlaybackRequiresUserGesture = options.mediaPlaybackRequiresUserAction + } // setup WebViewClient and WebChromeClient webView.webViewClient = @@ -321,11 +402,17 @@ class OSIABWebViewActivity : AppCompatActivity() { } override fun onPageFinished(view: WebView?, url: String?) { + val resolvedUrl = when { + url == null -> null + url.startsWith(PDF_VIEWER_URL_PREFIX) && originalUrl != null -> originalUrl + else -> url + } + if (isFirstLoad && !hasLoadError) { sendWebViewEvent(OSIABEvents.BrowserPageLoaded(browserId)) isFirstLoad = false } else if (!hasLoadError) { - sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, url)) + sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, resolvedUrl)) } // set back to false so that the next successful load @@ -335,7 +422,7 @@ class OSIABWebViewActivity : AppCompatActivity() { // store cookies after page finishes loading storeCookies() if (hasNavigationButtons) updateNavigationButtons() - if (showURL) urlText.text = url + if (showURL) urlText.text = resolvedUrl currentUrl = url super.onPageFinished(view, url) } @@ -368,7 +455,7 @@ class OSIABWebViewActivity : AppCompatActivity() { } // handle every http and https link by loading it in the WebView urlString.startsWith("http:") || urlString.startsWith("https:") -> { - view?.loadUrl(urlString) + handleLoadUrl(urlString) if (showURL) urlText.text = urlString true } @@ -646,7 +733,7 @@ class OSIABWebViewActivity : AppCompatActivity() { return findViewById(R.id.reload_button).apply { setOnClickListener { currentUrl?.let { - webView.loadUrl(it) + handleLoadUrl(it) showLoadingScreen() } } From 84abad4e720717eeb12d871a8e861baf83694566 Mon Sep 17 00:00:00 2001 From: Andre Destro Date: Wed, 13 Aug 2025 15:58:02 +0100 Subject: [PATCH 2/6] chore: update CHANGELOG.md --- CHANGELOG.md | 6 ++++++ .../osinappbrowserlib/views/OSIABWebViewActivity.kt | 3 +++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ee9ed..21dcc8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Features + +- Add support for PDF files in the WebView via PDF.js [RMET-2053](https://outsystemsrd.atlassian.net/browse/RMET-2053) + ## [1.4.1] ### Features diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt index 5d7461a..a896b87 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt @@ -241,6 +241,9 @@ class OSIABWebViewActivity : AppCompatActivity() { fun isContentTypeApplicationPdf(urlString: String): Boolean { return try { + // Try to identify if the URL is a PDF using a HEAD request. + // If the server does not implement HEAD correctly or does not return the expected content-type, + // fall back to a GET request, since some servers only return the correct type for GET. if (checkPdfByRequest(urlString, method = "HEAD")) { true } else { From bfc184c4f59518cde30533d99472ac82e80ce74a Mon Sep 17 00:00:00 2001 From: Andre Destro Date: Thu, 14 Aug 2025 14:36:32 +0100 Subject: [PATCH 3/6] refactor: move pdf logic to OSIABPdfHelper --- .../helpers/OSIABPdfHelper.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt new file mode 100644 index 0000000..9339696 --- /dev/null +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt @@ -0,0 +1,57 @@ +package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers + +import android.content.Context +import java.io.File +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +object OSIABPdfHelper { + + fun isContentTypeApplicationPdf(urlString: String): Boolean { + return try { + // Try to identify if the URL is a PDF using a HEAD request. + // If the server does not implement HEAD correctly or does not return the expected content-type, + // fall back to a GET request, since some servers only return the correct type for GET. + if (checkPdfByRequest(urlString, method = "HEAD")) { + true + } else { + checkPdfByRequest(urlString, method = "GET") + } + } catch (_: Exception) { + false + } + } + + fun checkPdfByRequest(urlString: String, method: String): Boolean { + var conn: HttpURLConnection? = null + return try { + conn = (URL(urlString).openConnection() as? HttpURLConnection) + conn?.run { + instanceFollowRedirects = true + requestMethod = method + if (method == "GET") { + setRequestProperty("Range", "bytes=0-0") + } + connect() + val type = contentType?.lowercase() + val disposition = getHeaderField("Content-Disposition")?.lowercase() + type == "application/pdf" || + (type.isNullOrEmpty() && disposition?.contains(".pdf") == true) + } ?: false + } finally { + conn?.disconnect() + } + } + + @Throws(IOException::class) + fun downloadPdfToCache(context: Context, url: String): File { + val pdfFile = File(context.cacheDir, "temp_${System.currentTimeMillis()}.pdf") + URL(url).openStream().use { input -> + pdfFile.outputStream().use { output -> + input.copyTo(output) + } + } + return pdfFile + } +} From 09d63f014c98c111b6f3c5658b4427e9da711413 Mon Sep 17 00:00:00 2001 From: Andre Destro Date: Thu, 14 Aug 2025 14:37:21 +0100 Subject: [PATCH 4/6] feat: clean PDF.js cache if needed --- .../views/OSIABWebViewActivity.kt | 62 ++++--------------- 1 file changed, 12 insertions(+), 50 deletions(-) diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt index a896b87..22852cc 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt @@ -38,15 +38,13 @@ import androidx.lifecycle.lifecycleScope import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents.OSIABWebViewEvent import com.outsystems.plugins.inappbrowser.osinappbrowserlib.R +import com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers.OSIABPdfHelper import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABToolbarPosition import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABWebViewOptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL class OSIABWebViewActivity : AppCompatActivity() { @@ -219,8 +217,8 @@ class OSIABWebViewActivity : AppCompatActivity() { private fun handleLoadUrl(url: String, additionalHttpHeaders: Map? = null) { lifecycleScope.launch(Dispatchers.IO) { - if (isContentTypeApplicationPdf(url)) { - val pdfFile = try { downloadPdfToCache(url) } catch (_: IOException) { null } + if (OSIABPdfHelper.isContentTypeApplicationPdf(url)) { + val pdfFile = try { OSIABPdfHelper.downloadPdfToCache(this@OSIABWebViewActivity, url) } catch (_: IOException) { null } if (pdfFile != null) { withContext(Dispatchers.Main) { webView.stopLoading() @@ -239,51 +237,6 @@ class OSIABWebViewActivity : AppCompatActivity() { } } - fun isContentTypeApplicationPdf(urlString: String): Boolean { - return try { - // Try to identify if the URL is a PDF using a HEAD request. - // If the server does not implement HEAD correctly or does not return the expected content-type, - // fall back to a GET request, since some servers only return the correct type for GET. - if (checkPdfByRequest(urlString, method = "HEAD")) { - true - } else { - checkPdfByRequest(urlString, method = "GET") - } - } catch (_: Exception) { - false - } - } - - private fun checkPdfByRequest(urlString: String, method: String): Boolean { - var conn: HttpURLConnection? = null - return try { - conn = (URL(urlString).openConnection() as? HttpURLConnection) - conn?.run { - instanceFollowRedirects = true - requestMethod = method - if (method == "GET") { - setRequestProperty("Range", "bytes=0-0") - } - connect() - val type = contentType?.lowercase() - val disposition = getHeaderField("Content-Disposition")?.lowercase() - type == "application/pdf" || - (type.isNullOrEmpty() && disposition?.contains(".pdf") == true) - } ?: false - } finally { - conn?.disconnect() - } - } - - private fun downloadPdfToCache(url: String): File { - val pdfFile = File(cacheDir, "temp_${System.currentTimeMillis()}.pdf") - URL(url).openStream().use { input -> - pdfFile.outputStream().use { output -> - input.copyTo(output) - } - } - return pdfFile - } /** * Helper function to update navigation button states @@ -418,6 +371,15 @@ class OSIABWebViewActivity : AppCompatActivity() { sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, resolvedUrl)) } + if (url?.startsWith(PDF_VIEWER_URL_PREFIX) == true) { + if (options.clearCache) { + webView.evaluateJavascript( + "localStorage.clear(); sessionStorage.clear();", null + ); + } + } + + // set back to false so that the next successful load // if the load fails, onReceivedError takes care of setting it back to true hasLoadError = false From ecffb88455459a314d0628a82190a9c07fcc0d6a Mon Sep 17 00:00:00 2001 From: Andre Destro Date: Thu, 14 Aug 2025 15:43:02 +0100 Subject: [PATCH 5/6] test: add unit tests for OSIABPdfHelper --- build.gradle | 1 + .../helpers/OSIABPdfHelper.kt | 12 +- .../views/OSIABWebViewActivity.kt | 11 +- .../helpers/OSIABPdfHelperTest.kt | 208 ++++++++++++++++++ 4 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelperTest.kt diff --git a/build.gradle b/build.gradle index f0dde17..ae20da6 100644 --- a/build.gradle +++ b/build.gradle @@ -135,6 +135,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.12.2' testImplementation 'org.mockito:mockito-core:5.12.0' + testImplementation "io.mockk:mockk:1.13.10" testImplementation 'androidx.test:core:1.6.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' } diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt index 9339696..d73feb6 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt @@ -7,6 +7,14 @@ import java.net.HttpURLConnection import java.net.URL object OSIABPdfHelper { + + interface UrlFactory { + fun create(url: String): URL + } + + private class DefaultUrlFactory : UrlFactory { + override fun create(url: String): URL = URL(url) + } fun isContentTypeApplicationPdf(urlString: String): Boolean { return try { @@ -23,10 +31,10 @@ object OSIABPdfHelper { } } - fun checkPdfByRequest(urlString: String, method: String): Boolean { + fun checkPdfByRequest(urlString: String, method: String, urlFactory: UrlFactory = DefaultUrlFactory()): Boolean { var conn: HttpURLConnection? = null return try { - conn = (URL(urlString).openConnection() as? HttpURLConnection) + conn = (urlFactory.create(urlString).openConnection() as? HttpURLConnection) conn?.run { instanceFollowRedirects = true requestMethod = method diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt index 22852cc..6712b29 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt @@ -371,15 +371,12 @@ class OSIABWebViewActivity : AppCompatActivity() { sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, resolvedUrl)) } - if (url?.startsWith(PDF_VIEWER_URL_PREFIX) == true) { - if (options.clearCache) { - webView.evaluateJavascript( - "localStorage.clear(); sessionStorage.clear();", null - ); - } + if (url?.startsWith(PDF_VIEWER_URL_PREFIX) == true && options.clearCache) { + webView.evaluateJavascript( + "localStorage.clear(); sessionStorage.clear();", null + ) } - // set back to false so that the next successful load // if the load fails, onReceivedError takes care of setting it back to true hasLoadError = false diff --git a/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelperTest.kt b/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelperTest.kt new file mode 100644 index 0000000..60bfcbf --- /dev/null +++ b/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelperTest.kt @@ -0,0 +1,208 @@ +package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.HttpURLConnection +import java.net.ServerSocket +import java.net.Socket +import java.net.URL +import java.nio.file.Files +import kotlin.concurrent.thread + +class OSIABPdfHelperTest { + + @Test + fun `isContentTypeApplicationPdf returns true if HEAD is PDF`() { + mockkObject(OSIABPdfHelper) + every { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } returns true + + val result = OSIABPdfHelper.isContentTypeApplicationPdf("http://example.com") + + assertTrue(result) + verify { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } + verify(exactly = 0) { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } + unmockkObject(OSIABPdfHelper) + } + + @Test + fun `isContentTypeApplicationPdf falls back to GET if HEAD fails`() { + mockkObject(OSIABPdfHelper) + every { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } returns false + every { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } returns true + + val result = OSIABPdfHelper.isContentTypeApplicationPdf("http://example.com") + + assertTrue(result) + verify { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } + verify { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } + unmockkObject(OSIABPdfHelper) + } + + @Test + fun `isContentTypeApplicationPdf returns false if both HEAD and GET fail`() { + mockkObject(OSIABPdfHelper) + every { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } returns false + every { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } returns false + + val result = OSIABPdfHelper.isContentTypeApplicationPdf("http://example.com") + + assertFalse(result) + verify { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } + verify { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } + unmockkObject(OSIABPdfHelper) + } + + @Test + fun `isContentTypeApplicationPdf returns false if exception occurs`() { + mockkObject(OSIABPdfHelper) + every { + OSIABPdfHelper.checkPdfByRequest( + any(), + any(), + any() + ) + } throws RuntimeException("Network error") + + val result = OSIABPdfHelper.isContentTypeApplicationPdf("http://example.com") + + assertFalse(result) + verify { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } + unmockkObject(OSIABPdfHelper) + } + + @Test + fun `returns true when content type is application_pdf`() { + val urlFactory = mockk() + val url = mockk() + val conn = mockk(relaxed = true) + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns conn + every { conn.contentType } returns "application/pdf" + every { conn.connect() } returns Unit + + val result = OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + + assertTrue(result) + verify { conn.connect() } + verify { conn.disconnect() } + } + + @Test + fun `returns true when disposition header contains pdf and content type is empty`() { + val urlFactory = mockk() + val url = mockk() + val conn = mockk(relaxed = true) + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns conn + every { conn.contentType } returns null + every { conn.getHeaderField("Content-Disposition") } returns "attachment; filename=test.pdf" + every { conn.connect() } returns Unit + + val result = OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + + assertTrue(result) + verify { conn.connect() } + verify { conn.disconnect() } + } + + @Test + fun `returns false when neither content type nor disposition indicate pdf`() { + val urlFactory = mockk() + val url = mockk() + val conn = mockk(relaxed = true) + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns conn + every { conn.contentType } returns "text/html" + every { conn.getHeaderField("Content-Disposition") } returns "inline" + every { conn.connect() } returns Unit + + val result = OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + + assertFalse(result) + verify { conn.connect() } + verify { conn.disconnect() } + } + + @Test + fun `sets Range header for GET method`() { + val urlFactory = mockk() + val url = mockk() + val conn = mockk(relaxed = true) + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns conn + every { conn.contentType } returns "application/pdf" + every { conn.connect() } returns Unit + + OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "GET", urlFactory) + + verify { conn.setRequestProperty("Range", "bytes=0-0") } + verify { conn.connect() } + verify { conn.disconnect() } + } + + @Test + fun `returns false if connection is null`() { + val urlFactory = mockk() + val url = mockk() + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns null + + val result = OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + + assertFalse(result) + } + + @Test + fun `returns false if exception is thrown`() { + val urlFactory = mockk() + every { urlFactory.create(any()) } throws RuntimeException("Network error") + + val result = try { + OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + } catch (_: Exception) { + false + } + + assertFalse(result) + } + + @Test + fun `downloadPdfToCache creates file with content`() { + val server = ServerSocket(0) + val port = server.localPort + val pdfBytes = "%PDF-1.4 test".toByteArray() + thread { + val client: Socket = server.accept() + val out = client.getOutputStream() + out.write( + ("HTTP/1.1 200 OK\r\n" + + "Content-Type: application/pdf\r\n" + + "Content-Length: ${pdfBytes.size}\r\n" + + "\r\n").toByteArray() + ) + out.write(pdfBytes) + out.flush() + client.close() + server.close() + } + + val context = mockk() + val cacheDir = Files.createTempDirectory("test_cache").toFile() + every { context.cacheDir } returns cacheDir + + val url = "http://localhost:$port/test.pdf" + val file = OSIABPdfHelper.downloadPdfToCache(context, url) + + assertTrue(file.exists()) + assertTrue(file.readBytes().copyOfRange(0, 8).contentEquals("%PDF-1.4".toByteArray())) + file.delete() + cacheDir.deleteRecursively() + } +} From 245ddd83330148d9655805d6811175f06dc55014 Mon Sep 17 00:00:00 2001 From: Andre Destro Date: Thu, 14 Aug 2025 19:04:05 +0100 Subject: [PATCH 6/6] fix: prevent duplicate onPageFinished events for PDF.js viewer --- .../osinappbrowserlib/views/OSIABWebViewActivity.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt index 6712b29..1128f27 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt @@ -357,7 +357,18 @@ class OSIABWebViewActivity : AppCompatActivity() { } } + var lastPageFinishedUrl: String? = null + override fun onPageFinished(view: WebView?, url: String?) { + if (url != null && url == lastPageFinishedUrl && url.startsWith(PDF_VIEWER_URL_PREFIX)) { + // If the url is the same as the last finished URL and it is a PDF viewer URL, + // we do not want to trigger the page finished event again. + // This prevents the event from being sent multiple times + // since PDF.js triggers onPageFinished multiple times during PDF rendering. + return + } + lastPageFinishedUrl = url + val resolvedUrl = when { url == null -> null url.startsWith(PDF_VIEWER_URL_PREFIX) && originalUrl != null -> originalUrl