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/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/build.gradle b/build.gradle index d6de6e9..ae20da6 100644 --- a/build.gradle +++ b/build.gradle @@ -135,10 +135,93 @@ 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' } 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/helpers/OSIABPdfHelper.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt new file mode 100644 index 0000000..d73feb6 --- /dev/null +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt @@ -0,0 +1,65 @@ +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 { + + 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 { + // 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, urlFactory: UrlFactory = DefaultUrlFactory()): Boolean { + var conn: HttpURLConnection? = null + return try { + conn = (urlFactory.create(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 + } +} 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..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 @@ -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 @@ -38,9 +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.IOException class OSIABWebViewActivity : AppCompatActivity() { @@ -88,6 +92,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 +182,7 @@ class OSIABWebViewActivity : AppCompatActivity() { setupWebView() if (urlToOpen != null) { - webView.loadUrl(urlToOpen, customHeaders ?: emptyMap()) + handleLoadUrl(urlToOpen, customHeaders) showLoadingScreen() } @@ -206,6 +215,29 @@ class OSIABWebViewActivity : AppCompatActivity() { } } + private fun handleLoadUrl(url: String, additionalHttpHeaders: Map? = null) { + lifecycleScope.launch(Dispatchers.IO) { + if (OSIABPdfHelper.isContentTypeApplicationPdf(url)) { + val pdfFile = try { OSIABPdfHelper.downloadPdfToCache(this@OSIABWebViewActivity, 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()) + } + } + } + + /** * Helper function to update navigation button states */ @@ -232,19 +264,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 = @@ -320,12 +357,35 @@ 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 + else -> url + } + if (isFirstLoad && !hasLoadError) { sendWebViewEvent(OSIABEvents.BrowserPageLoaded(browserId)) isFirstLoad = false } else if (!hasLoadError) { - sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, url)) + sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, resolvedUrl)) + } + + 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 @@ -335,7 +395,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 +428,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 +706,7 @@ class OSIABWebViewActivity : AppCompatActivity() { return findViewById(R.id.reload_button).apply { setOnClickListener { currentUrl?.let { - webView.loadUrl(it) + handleLoadUrl(it) showLoadingScreen() } } 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() + } +}