Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,7 @@ lint/tmp/
.DS_Store
Gemfile.lock

scripts/tmp
scripts/tmp

# PDF.js files
src/main/assets/pdfjs/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 84 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

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)
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -173,7 +182,7 @@ class OSIABWebViewActivity : AppCompatActivity() {

setupWebView()
if (urlToOpen != null) {
webView.loadUrl(urlToOpen, customHeaders ?: emptyMap())
handleLoadUrl(urlToOpen, customHeaders)
showLoadingScreen()
}

Expand Down Expand Up @@ -206,6 +215,29 @@ class OSIABWebViewActivity : AppCompatActivity() {
}
}

private fun handleLoadUrl(url: String, additionalHttpHeaders: Map<String, String>? = 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
*/
Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -646,7 +706,7 @@ class OSIABWebViewActivity : AppCompatActivity() {
return findViewById<Button?>(R.id.reload_button).apply {
setOnClickListener {
currentUrl?.let {
webView.loadUrl(it)
handleLoadUrl(it)
showLoadingScreen()
}
}
Expand Down
Loading
Loading