Skip to content

Commit 73acfa1

Browse files
authored
Merge pull request #51 from OutSystems/feat/RMET-2053/add-pdf-support
RMET-2053 :: Add Support for PDF
2 parents 67e46a1 + 245ddd8 commit 73acfa1

6 files changed

Lines changed: 445 additions & 20 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,7 @@ lint/tmp/
8787
.DS_Store
8888
Gemfile.lock
8989

90-
scripts/tmp
90+
scripts/tmp
91+
92+
# PDF.js files
93+
src/main/assets/pdfjs/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Features
10+
11+
- Add support for PDF files in the WebView via PDF.js [RMET-2053](https://outsystemsrd.atlassian.net/browse/RMET-2053)
12+
713
## [1.4.1]
814

915
### Features

build.gradle

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,93 @@ dependencies {
135135
testImplementation 'junit:junit:4.13.2'
136136
testImplementation 'org.robolectric:robolectric:4.12.2'
137137
testImplementation 'org.mockito:mockito-core:5.12.0'
138+
testImplementation "io.mockk:mockk:1.13.10"
138139
testImplementation 'androidx.test:core:1.6.1'
139140
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
140141
}
141142

142143
if (System.getenv("SHOULD_PUBLISH") == "true") {
143144
apply from: file("./scripts/publish-module.gradle")
144-
}
145+
}
146+
147+
import java.net.URL
148+
149+
def pdfJsVersion = "5.4.54"
150+
def pdfJsUrl = "https://github.com/mozilla/pdf.js/releases/download/v${pdfJsVersion}/pdfjs-${pdfJsVersion}-dist.zip"
151+
def pdfJsDestDir = "$projectDir/src/main/assets/pdfjs"
152+
def pdfJsCacheDir = new File(gradle.gradleUserHomeDir, "pdfjs-cache/${pdfJsVersion}")
153+
154+
tasks.register("downloadPdfJs") {
155+
description = "Downloads and caches PDF.js distribution files"
156+
group = "build setup"
157+
158+
// Define inputs/outputs for up-to-date checking
159+
inputs.property("pdfJsVersion", pdfJsVersion)
160+
outputs.dir(pdfJsDestDir)
161+
162+
doLast {
163+
def destDir = file(pdfJsDestDir)
164+
165+
// Check if destination already has files
166+
if (destDir.exists() && destDir.listFiles()?.size() > 0) {
167+
logger.info("PDF.js files already exist in ${destDir}")
168+
return
169+
}
170+
171+
// Check if cache has files and copy them
172+
if (pdfJsCacheDir.exists() && pdfJsCacheDir.listFiles()?.size() > 0) {
173+
logger.info("Copying PDF.js from cache: ${pdfJsCacheDir}")
174+
copy {
175+
from pdfJsCacheDir
176+
into destDir
177+
}
178+
return
179+
}
180+
181+
// Download PDF.js
182+
logger.info("Downloading PDF.js v${pdfJsVersion} from ${pdfJsUrl}")
183+
def zipFile = file("$buildDir/pdfjs-${pdfJsVersion}.zip")
184+
185+
try {
186+
// Ensure build directory exists
187+
zipFile.parentFile.mkdirs()
188+
189+
// Download with better error handling
190+
new URL(pdfJsUrl).withInputStream { inputStream ->
191+
zipFile.withOutputStream { outputStream ->
192+
outputStream << inputStream
193+
}
194+
}
195+
196+
logger.info("Downloaded PDF.js to: ${zipFile}")
197+
198+
// Extract to cache
199+
pdfJsCacheDir.mkdirs()
200+
copy {
201+
from zipTree(zipFile)
202+
into pdfJsCacheDir
203+
}
204+
205+
// Copy from cache to destination
206+
if (destDir.exists()) {
207+
delete destDir
208+
}
209+
destDir.mkdirs()
210+
copy {
211+
from pdfJsCacheDir
212+
into destDir
213+
}
214+
215+
logger.info("PDF.js extracted to: ${destDir}")
216+
217+
// Clean up downloaded zip
218+
delete zipFile
219+
220+
} catch (Exception e) {
221+
logger.error("Failed to download PDF.js: ${e.message}")
222+
throw new GradleException("Could not download PDF.js from ${pdfJsUrl}", e)
223+
}
224+
}
225+
}
226+
227+
preBuild.dependsOn(downloadPdfJs)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers
2+
3+
import android.content.Context
4+
import java.io.File
5+
import java.io.IOException
6+
import java.net.HttpURLConnection
7+
import java.net.URL
8+
9+
object OSIABPdfHelper {
10+
11+
interface UrlFactory {
12+
fun create(url: String): URL
13+
}
14+
15+
private class DefaultUrlFactory : UrlFactory {
16+
override fun create(url: String): URL = URL(url)
17+
}
18+
19+
fun isContentTypeApplicationPdf(urlString: String): Boolean {
20+
return try {
21+
// Try to identify if the URL is a PDF using a HEAD request.
22+
// If the server does not implement HEAD correctly or does not return the expected content-type,
23+
// fall back to a GET request, since some servers only return the correct type for GET.
24+
if (checkPdfByRequest(urlString, method = "HEAD")) {
25+
true
26+
} else {
27+
checkPdfByRequest(urlString, method = "GET")
28+
}
29+
} catch (_: Exception) {
30+
false
31+
}
32+
}
33+
34+
fun checkPdfByRequest(urlString: String, method: String, urlFactory: UrlFactory = DefaultUrlFactory()): Boolean {
35+
var conn: HttpURLConnection? = null
36+
return try {
37+
conn = (urlFactory.create(urlString).openConnection() as? HttpURLConnection)
38+
conn?.run {
39+
instanceFollowRedirects = true
40+
requestMethod = method
41+
if (method == "GET") {
42+
setRequestProperty("Range", "bytes=0-0")
43+
}
44+
connect()
45+
val type = contentType?.lowercase()
46+
val disposition = getHeaderField("Content-Disposition")?.lowercase()
47+
type == "application/pdf" ||
48+
(type.isNullOrEmpty() && disposition?.contains(".pdf") == true)
49+
} ?: false
50+
} finally {
51+
conn?.disconnect()
52+
}
53+
}
54+
55+
@Throws(IOException::class)
56+
fun downloadPdfToCache(context: Context, url: String): File {
57+
val pdfFile = File(context.cacheDir, "temp_${System.currentTimeMillis()}.pdf")
58+
URL(url).openStream().use { input ->
59+
pdfFile.outputStream().use { output ->
60+
input.copyTo(output)
61+
}
62+
}
63+
return pdfFile
64+
}
65+
}

src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import android.Manifest
44
import android.app.Activity
55
import android.content.Intent
66
import android.content.pm.PackageManager
7+
import android.graphics.Bitmap
78
import android.net.Uri
89
import android.os.Build
910
import android.os.Bundle
10-
import android.view.Gravity
1111
import android.util.Log
12-
import android.graphics.Bitmap
12+
import android.view.Gravity
1313
import android.view.View
1414
import android.webkit.CookieManager
1515
import android.webkit.GeolocationPermissions
@@ -38,9 +38,13 @@ import androidx.lifecycle.lifecycleScope
3838
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents
3939
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents.OSIABWebViewEvent
4040
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.R
41+
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers.OSIABPdfHelper
4142
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABToolbarPosition
4243
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABWebViewOptions
44+
import kotlinx.coroutines.Dispatchers
4345
import kotlinx.coroutines.launch
46+
import kotlinx.coroutines.withContext
47+
import java.io.IOException
4448

4549
class OSIABWebViewActivity : AppCompatActivity() {
4650

@@ -88,6 +92,11 @@ class OSIABWebViewActivity : AppCompatActivity() {
8892
// for back navigation
8993
private lateinit var onBackPressedCallback: OnBackPressedCallback
9094

95+
private val PDF_VIEWER_URL_PREFIX = "file:///android_asset/pdfjs/web/viewer.html?file="
96+
// the original URL of the PDF file, used to display it correctly in the view
97+
// and to send the correct URL in the browserPageNavigationCompleted event
98+
private var originalUrl: String? = null
99+
91100
companion object {
92101
const val WEB_VIEW_URL_EXTRA = "WEB_VIEW_URL_EXTRA"
93102
const val WEB_VIEW_OPTIONS_EXTRA = "WEB_VIEW_OPTIONS_EXTRA"
@@ -173,7 +182,7 @@ class OSIABWebViewActivity : AppCompatActivity() {
173182

174183
setupWebView()
175184
if (urlToOpen != null) {
176-
webView.loadUrl(urlToOpen, customHeaders ?: emptyMap())
185+
handleLoadUrl(urlToOpen, customHeaders)
177186
showLoadingScreen()
178187
}
179188

@@ -206,6 +215,29 @@ class OSIABWebViewActivity : AppCompatActivity() {
206215
}
207216
}
208217

218+
private fun handleLoadUrl(url: String, additionalHttpHeaders: Map<String, String>? = null) {
219+
lifecycleScope.launch(Dispatchers.IO) {
220+
if (OSIABPdfHelper.isContentTypeApplicationPdf(url)) {
221+
val pdfFile = try { OSIABPdfHelper.downloadPdfToCache(this@OSIABWebViewActivity, url) } catch (_: IOException) { null }
222+
if (pdfFile != null) {
223+
withContext(Dispatchers.Main) {
224+
webView.stopLoading()
225+
originalUrl = url
226+
val pdfJsUrl =
227+
PDF_VIEWER_URL_PREFIX + Uri.encode("file://${pdfFile.absolutePath}")
228+
webView.loadUrl(pdfJsUrl)
229+
}
230+
return@launch
231+
}
232+
}
233+
234+
withContext(Dispatchers.Main) {
235+
webView.loadUrl(url, additionalHttpHeaders ?: emptyMap())
236+
}
237+
}
238+
}
239+
240+
209241
/**
210242
* Helper function to update navigation button states
211243
*/
@@ -232,19 +264,24 @@ class OSIABWebViewActivity : AppCompatActivity() {
232264
* It also deals with URLs that are opened withing the WebView.
233265
*/
234266
private fun setupWebView() {
235-
webView.settings.javaScriptEnabled = true
236-
webView.settings.javaScriptCanOpenWindowsAutomatically = true
237-
webView.settings.databaseEnabled = true
238-
webView.settings.domStorageEnabled = true
239-
webView.settings.loadWithOverviewMode = true
240-
webView.settings.useWideViewPort = true
267+
webView.settings.apply {
268+
javaScriptEnabled = true
269+
javaScriptCanOpenWindowsAutomatically = true
270+
databaseEnabled = true
271+
domStorageEnabled = true
272+
loadWithOverviewMode = true
273+
useWideViewPort = true
274+
allowFileAccess = true
275+
allowFileAccessFromFileURLs = true
276+
allowUniversalAccessFromFileURLs = true
241277

242-
if (!options.customUserAgent.isNullOrEmpty())
243-
webView.settings.userAgentString = options.customUserAgent
278+
if (!options.customUserAgent.isNullOrEmpty())
279+
userAgentString = options.customUserAgent
244280

245-
// get webView settings that come from options
246-
webView.settings.builtInZoomControls = options.allowZoom
247-
webView.settings.mediaPlaybackRequiresUserGesture = options.mediaPlaybackRequiresUserAction
281+
// get webView settings that come from options
282+
builtInZoomControls = options.allowZoom
283+
mediaPlaybackRequiresUserGesture = options.mediaPlaybackRequiresUserAction
284+
}
248285

249286
// setup WebViewClient and WebChromeClient
250287
webView.webViewClient =
@@ -320,12 +357,35 @@ class OSIABWebViewActivity : AppCompatActivity() {
320357
}
321358
}
322359

360+
var lastPageFinishedUrl: String? = null
361+
323362
override fun onPageFinished(view: WebView?, url: String?) {
363+
if (url != null && url == lastPageFinishedUrl && url.startsWith(PDF_VIEWER_URL_PREFIX)) {
364+
// If the url is the same as the last finished URL and it is a PDF viewer URL,
365+
// we do not want to trigger the page finished event again.
366+
// This prevents the event from being sent multiple times
367+
// since PDF.js triggers onPageFinished multiple times during PDF rendering.
368+
return
369+
}
370+
lastPageFinishedUrl = url
371+
372+
val resolvedUrl = when {
373+
url == null -> null
374+
url.startsWith(PDF_VIEWER_URL_PREFIX) && originalUrl != null -> originalUrl
375+
else -> url
376+
}
377+
324378
if (isFirstLoad && !hasLoadError) {
325379
sendWebViewEvent(OSIABEvents.BrowserPageLoaded(browserId))
326380
isFirstLoad = false
327381
} else if (!hasLoadError) {
328-
sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, url))
382+
sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, resolvedUrl))
383+
}
384+
385+
if (url?.startsWith(PDF_VIEWER_URL_PREFIX) == true && options.clearCache) {
386+
webView.evaluateJavascript(
387+
"localStorage.clear(); sessionStorage.clear();", null
388+
)
329389
}
330390

331391
// set back to false so that the next successful load
@@ -335,7 +395,7 @@ class OSIABWebViewActivity : AppCompatActivity() {
335395
// store cookies after page finishes loading
336396
storeCookies()
337397
if (hasNavigationButtons) updateNavigationButtons()
338-
if (showURL) urlText.text = url
398+
if (showURL) urlText.text = resolvedUrl
339399
currentUrl = url
340400
super.onPageFinished(view, url)
341401
}
@@ -368,7 +428,7 @@ class OSIABWebViewActivity : AppCompatActivity() {
368428
}
369429
// handle every http and https link by loading it in the WebView
370430
urlString.startsWith("http:") || urlString.startsWith("https:") -> {
371-
view?.loadUrl(urlString)
431+
handleLoadUrl(urlString)
372432
if (showURL) urlText.text = urlString
373433
true
374434
}
@@ -646,7 +706,7 @@ class OSIABWebViewActivity : AppCompatActivity() {
646706
return findViewById<Button?>(R.id.reload_button).apply {
647707
setOnClickListener {
648708
currentUrl?.let {
649-
webView.loadUrl(it)
709+
handleLoadUrl(it)
650710
showLoadingScreen()
651711
}
652712
}

0 commit comments

Comments
 (0)