Skip to content
Open
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
137 changes: 137 additions & 0 deletions core/src/main/java/in/testpress/util/webview/CustomWebViewClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@ package `in`.testpress.util.webview
import `in`.testpress.core.TestpressException
import `in`.testpress.fragments.WebViewFragment
import `in`.testpress.util.extension.openUrlInBrowser
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import android.webkit.*
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.util.regex.Pattern

class CustomWebViewClient(val fragment: WebViewFragment) : WebViewClient() {

private var errorList = linkedMapOf<WebResourceRequest?,WebResourceResponse?>()

companion object {
private const val TAG = "CustomWebViewClient"
private const val LOCAL_PDF_SCHEME = "https://local.pdf/"
private val PDF_ID_PATTERN = Pattern.compile("^https://local\\.pdf/([^/]+)$")
private val SAFE_ID_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]")
}

override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url.toString()
Expand All @@ -25,6 +38,63 @@ class CustomWebViewClient(val fragment: WebViewFragment) : WebViewClient() {
}
}

override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
val url = request?.url?.toString() ?: return super.shouldInterceptRequest(view, request)

Log.d(TAG, "Intercepting request: $url")

// Check if this is a local PDF request
if (!url.startsWith(LOCAL_PDF_SCHEME)) {
return super.shouldInterceptRequest(view, request)
}

// Handle OPTIONS requests (CORS preflight)
if (request?.method == "OPTIONS") {
Log.d(TAG, "Handling CORS preflight request for: $url")
return createCorsPreflightResponse()
}

// Extract PDF ID from URL
val matcher = PDF_ID_PATTERN.matcher(url)
if (!matcher.matches()) {
Log.w(TAG, "Invalid local PDF URL format: $url")
return createErrorResponse(400, "Bad Request")
}

val pdfId = matcher.group(1)
Log.d(TAG, "Extracted PDF ID: $pdfId")

// Sanitize PDF ID to prevent path traversal
val safeId = SAFE_ID_PATTERN.matcher(pdfId).replaceAll("_")
Log.d(TAG, "Sanitized PDF ID: $safeId")

// Resolve cached file location
val context = fragment.requireContext()
val cacheDir = File(context.cacheDir, "pdf-cache")
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}

val pdfFile = File(cacheDir, "$safeId.pdf")
Log.d(TAG, "Looking for PDF file: ${pdfFile.absolutePath}")

return try {
if (pdfFile.exists() && pdfFile.isFile) {
Log.d(TAG, "Serving PDF file: ${pdfFile.absolutePath} (${pdfFile.length()} bytes)")
createPdfResponse(pdfFile)
} else {
Log.w(TAG, "PDF file not found: ${pdfFile.absolutePath}")
createErrorResponse(404, "Not Found")
}
} catch (e: Exception) {
Log.e(TAG, "Error serving PDF file", e)
createErrorResponse(500, "Internal Error")
}
}

private fun isPDFUrl(url: String?) = url?.contains(".pdf") ?: false

private fun shouldLoadInWebView(url: String?):Boolean {
Expand Down Expand Up @@ -80,4 +150,71 @@ class CustomWebViewClient(val fragment: WebViewFragment) : WebViewClient() {
}
}
}

private fun createPdfResponse(pdfFile: File): WebResourceResponse {
return try {
val inputStream = FileInputStream(pdfFile)
val corsHeaders = mutableMapOf<String, String>().apply {
put("Access-Control-Allow-Origin", "*") // Allow all origins for WebView
put("Access-Control-Allow-Methods", "GET, OPTIONS")
put("Access-Control-Allow-Headers", "Content-Type, Range")
put("Access-Control-Expose-Headers", "Content-Length, Accept-Ranges")
put("Cache-Control", "private, max-age=3600")
put("Content-Length", pdfFile.length().toString())
put("Content-Type", "application/pdf")
}

WebResourceResponse(
"application/pdf",
"binary",
200,
"OK",
corsHeaders,
inputStream
)
} catch (e: IOException) {
Log.e(TAG, "Error creating PDF response", e)
createErrorResponse(500, "Internal Error")
}
}

private fun createCorsPreflightResponse(): WebResourceResponse {
val corsHeaders = mutableMapOf<String, String>().apply {
put("Access-Control-Allow-Origin", "*")
put("Access-Control-Allow-Methods", "GET, OPTIONS")
put("Access-Control-Allow-Headers", "Content-Type, Range")
put("Access-Control-Max-Age", "86400") // 24 hours
}

Log.d(TAG, "Creating CORS preflight response")

return WebResourceResponse(
"text/plain",
"utf-8",
200,
"OK",
corsHeaders,
"".byteInputStream()
)
}

private fun createErrorResponse(status: Int, message: String): WebResourceResponse {
val statusText = when (status) {
400 -> "Bad Request"
404 -> "Not Found"
500 -> "Internal Server Error"
else -> "Error"
}

Log.d(TAG, "Creating error response: $status $statusText - $message")

return WebResourceResponse(
"text/plain",
"utf-8",
status,
statusText,
mapOf("Content-Type" to "text/plain"),
message.byteInputStream()
)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package `in`.testpress.course.fragments

import `in`.testpress.course.R
import `in`.testpress.course.util.PdfCacheManager
import `in`.testpress.course.util.EnhancedPdfProvider
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
Expand All @@ -18,8 +21,12 @@ class AIChatPdfFragment : Fragment() {
companion object {
private const val ARG_CONTENT_ID = "contentId"
private const val ARG_COURSE_ID = "courseId"
private const val ARG_PDF_PATH = "pdfPath"
}

private lateinit var pdfCacheManager: PdfCacheManager
private var pdfId: String? = null

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
Expand All @@ -33,11 +40,26 @@ class AIChatPdfFragment : Fragment() {

val contentId = requireArguments().getLong(ARG_CONTENT_ID, -1L)
val courseId = requireArguments().getLong(ARG_COURSE_ID, -1L)
val pdfPath = requireArguments().getString(ARG_PDF_PATH, "")

if (contentId == -1L || courseId == -1L) {
throw IllegalArgumentException("Required arguments (contentId, courseId) are missing or invalid.")
}

// Initialize PDF cache manager
pdfCacheManager = PdfCacheManager.getInstance(requireContext())

// Register PDF for streaming if path is provided
if (pdfPath.isNotEmpty()) {
try {
pdfId = pdfCacheManager.registerPdf(pdfPath)
Log.d("AIChatPdfFragment", "Registered PDF for streaming with ID: $pdfId")
} catch (e: Exception) {
Log.e("AIChatPdfFragment", "Failed to register PDF for streaming", e)
// Fall back to original URL-based approach
}
}

val webViewFragment = WebViewFragment()

val pdfUrl = getPdfUrl(courseId, contentId)
Expand All @@ -47,6 +69,12 @@ class AIChatPdfFragment : Fragment() {
putBoolean(IS_AUTHENTICATION_REQUIRED, true)
}

webViewFragment.setListener(object : WebViewFragment.Listener {
override fun onWebViewInitializationSuccess() {
setupJavaScriptInterface(webViewFragment)
}
})

childFragmentManager.beginTransaction()
.replace(R.id.aiPdf_view_fragment, webViewFragment)
.commit()
Expand All @@ -59,4 +87,26 @@ class AIChatPdfFragment : Fragment() {
?: throw IllegalStateException("Base URL not configured.")
return "$baseUrl/courses/$courseId/contents/$contentId/?content_detail_v2=true"
}

private fun setupJavaScriptInterface(webViewFragment: WebViewFragment) {
val pdfPath = requireArguments().getString(ARG_PDF_PATH, "")
Log.d("AIChatPdfFragment", "Setting up JavaScript interface with PDF path: $pdfPath")

webViewFragment.webView.settings.apply {
allowFileAccessFromFileURLs = true
allowUniversalAccessFromFileURLs = true
}
Comment on lines +95 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Enabling allowFileAccessFromFileURLs and allowUniversalAccessFromFileURLs poses a significant security risk, especially when loading content from a remote URL. These settings could allow malicious JavaScript on the web page to access local files on the user's device.

Since the PDF data is being provided to the WebView via a JavaScript interface as a Base64-encoded data URL, direct file system access from the WebView is not necessary. Please remove these settings to enhance security.


// Create enhanced JavaScript interface that provides streaming URL
val jsInterface = EnhancedPdfProvider(requireActivity(), pdfPath, pdfId)
webViewFragment.addJavascriptInterface(jsInterface, "AndroidPdfCache")

Log.d("AIChatPdfFragment", "Enhanced JavaScript interface 'AndroidPdfCache' added successfully")
}

override fun onDestroy() {
super.onDestroy()
// Clean up registered PDF
pdfId?.let { pdfCacheManager.unregisterPdf(it) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class DocumentViewerFragment : BaseContentDetailFragment(), PdfDownloadListener,
val args = Bundle()
args.putLong("contentId", contentId)
args.putLong("courseId", content.courseId ?: -1L)
args.putString("pdfPath", pdfDownloadManager.getCachedPdfPath())
aiChatFragment?.arguments = args
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package `in`.testpress.course.util

import android.app.Activity
import `in`.testpress.util.BaseJavaScriptInterface
import android.webkit.JavascriptInterface
import android.util.Log
import java.io.File

class EnhancedPdfProvider(
activity: Activity,
private val pdfPath: String,
private val pdfId: String?
) : BaseJavaScriptInterface(activity) {

companion object {
private const val TAG = "EnhancedPdfProvider"
}

init {
Log.d(TAG, "Initialized with PDF path: $pdfPath, PDF ID: $pdfId")
}

@JavascriptInterface
fun isPDFCached(): Boolean {
val exists = !pdfPath.isEmpty() && File(pdfPath).exists()
Log.d(TAG, "isPDFCached() called - Result: $exists")
return exists
}

@JavascriptInterface
fun getStreamingUrl(): String {
return if (pdfId != null && isPDFCached()) {
val url = "https://local.pdf/$pdfId"
Log.d(TAG, "getStreamingUrl() called - Result: $url")
url
} else {
Log.d(TAG, "getStreamingUrl() called - No PDF ID available, returning empty")
""
}
}

@JavascriptInterface
fun getPdfInfo(): String {
val info = mapOf(
"isCached" to isPDFCached(),
"hasStreamingUrl" to (pdfId != null),
"streamingUrl" to getStreamingUrl(),
"fileSize" to if (isPDFCached()) File(pdfPath).length() else 0L
)
Log.d(TAG, "getPdfInfo() called - Result: $info")
return info.toString()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ open class PDFDownloadManager(
fun get(): File {
return fileEncryptAndDecryptUtil.decrypt()
}

fun getCachedPdfPath(): String {
return if (isDownloaded()) get().absolutePath else ""
}
}

interface PdfDownloadListener {
Expand Down
Loading