diff --git a/core/src/main/java/in/testpress/util/webview/CustomWebViewClient.kt b/core/src/main/java/in/testpress/util/webview/CustomWebViewClient.kt index c3f6f06ef..6d98f8474 100644 --- a/core/src/main/java/in/testpress/util/webview/CustomWebViewClient.kt +++ b/core/src/main/java/in/testpress/util/webview/CustomWebViewClient.kt @@ -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() + + 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() @@ -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 { @@ -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().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().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() + ) + } } \ No newline at end of file diff --git a/course/src/main/java/in/testpress/course/fragments/AIChatPdfFragment.kt b/course/src/main/java/in/testpress/course/fragments/AIChatPdfFragment.kt index 25e41e33a..1820953f4 100644 --- a/course/src/main/java/in/testpress/course/fragments/AIChatPdfFragment.kt +++ b/course/src/main/java/in/testpress/course/fragments/AIChatPdfFragment.kt @@ -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 @@ -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?, @@ -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) @@ -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() @@ -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 + } + + // 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) } + } } diff --git a/course/src/main/java/in/testpress/course/fragments/DocumentViewerFragment.kt b/course/src/main/java/in/testpress/course/fragments/DocumentViewerFragment.kt index b67ff31d8..77bf4f6b9 100644 --- a/course/src/main/java/in/testpress/course/fragments/DocumentViewerFragment.kt +++ b/course/src/main/java/in/testpress/course/fragments/DocumentViewerFragment.kt @@ -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 } diff --git a/course/src/main/java/in/testpress/course/util/EnhancedPdfProvider.kt b/course/src/main/java/in/testpress/course/util/EnhancedPdfProvider.kt new file mode 100644 index 000000000..f90d04afb --- /dev/null +++ b/course/src/main/java/in/testpress/course/util/EnhancedPdfProvider.kt @@ -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() + } +} diff --git a/course/src/main/java/in/testpress/course/util/PDFDownloadManager.kt b/course/src/main/java/in/testpress/course/util/PDFDownloadManager.kt index e72ea3571..fbe1bbdcc 100644 --- a/course/src/main/java/in/testpress/course/util/PDFDownloadManager.kt +++ b/course/src/main/java/in/testpress/course/util/PDFDownloadManager.kt @@ -73,6 +73,10 @@ open class PDFDownloadManager( fun get(): File { return fileEncryptAndDecryptUtil.decrypt() } + + fun getCachedPdfPath(): String { + return if (isDownloaded()) get().absolutePath else "" + } } interface PdfDownloadListener { diff --git a/course/src/main/java/in/testpress/course/util/PdfCacheManager.kt b/course/src/main/java/in/testpress/course/util/PdfCacheManager.kt new file mode 100644 index 000000000..b2d740ee6 --- /dev/null +++ b/course/src/main/java/in/testpress/course/util/PdfCacheManager.kt @@ -0,0 +1,121 @@ +package `in`.testpress.course.util + +import android.content.Context +import android.util.Log +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +class PdfCacheManager private constructor(private val context: Context) { + + companion object { + private const val TAG = "PdfCacheManager" + private const val CACHE_DIR_NAME = "pdf-cache" + + @Volatile + private var INSTANCE: PdfCacheManager? = null + + fun getInstance(context: Context): PdfCacheManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: PdfCacheManager(context.applicationContext).also { INSTANCE = it } + } + } + } + + private val pdfIdToPathMap = ConcurrentHashMap() + private val cacheDir: File by lazy { + File(context.cacheDir, CACHE_DIR_NAME).apply { + if (!exists()) { + mkdirs() + } + } + } + + /** + * Register a PDF file with a unique ID for local streaming + * @param pdfPath The actual file path of the PDF + * @return A unique PDF ID that can be used in https://local.pdf/{pdfId} URLs + */ + fun registerPdf(pdfPath: String): String { + val pdfFile = File(pdfPath) + if (!pdfFile.exists() || !pdfFile.isFile) { + throw IllegalArgumentException("PDF file does not exist: $pdfPath") + } + + // Generate a unique ID based on file path and modification time + val uniqueId = generateUniqueId(pdfPath, pdfFile.lastModified()) + + // Copy file to cache directory if not already there + val cachedFile = File(cacheDir, "$uniqueId.pdf") + if (!cachedFile.exists()) { + try { + pdfFile.copyTo(cachedFile, overwrite = true) + Log.d(TAG, "Cached PDF: $pdfPath -> ${cachedFile.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Failed to cache PDF: $pdfPath", e) + throw RuntimeException("Failed to cache PDF file", e) + } + } + + pdfIdToPathMap[uniqueId] = cachedFile.absolutePath + Log.d(TAG, "Registered PDF with ID: $uniqueId") + + return uniqueId + } + + /** + * Get the local PDF URL for a registered PDF ID + */ + fun getLocalPdfUrl(pdfId: String): String { + return "https://local.pdf/$pdfId" + } + + /** + * Check if a PDF ID is registered + */ + fun isPdfRegistered(pdfId: String): Boolean { + return pdfIdToPathMap.containsKey(pdfId) + } + + /** + * Get the cached file path for a PDF ID + */ + fun getCachedFilePath(pdfId: String): String? { + return pdfIdToPathMap[pdfId] + } + + /** + * Unregister a PDF ID (cleanup) + */ + fun unregisterPdf(pdfId: String) { + val cachedPath = pdfIdToPathMap.remove(pdfId) + if (cachedPath != null) { + try { + File(cachedPath).delete() + Log.d(TAG, "Unregistered and deleted PDF: $pdfId") + } catch (e: Exception) { + Log.w(TAG, "Failed to delete cached PDF: $cachedPath", e) + } + } + } + + /** + * Clear all cached PDFs + */ + fun clearCache() { + try { + cacheDir.listFiles()?.forEach { it.delete() } + pdfIdToPathMap.clear() + Log.d(TAG, "Cleared PDF cache") + } catch (e: Exception) { + Log.e(TAG, "Failed to clear PDF cache", e) + } + } + + private fun generateUniqueId(pdfPath: String, lastModified: Long): String { + val input = "$pdfPath:$lastModified" + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(input.toByteArray()) + return hash.joinToString("") { "%02x".format(it) }.take(16) + } +} diff --git a/samples/src/main/java/in/testpress/samples/core/TestpressCoreSampleActivity.java b/samples/src/main/java/in/testpress/samples/core/TestpressCoreSampleActivity.java index 9ba2a1ce7..d02d20506 100644 --- a/samples/src/main/java/in/testpress/samples/core/TestpressCoreSampleActivity.java +++ b/samples/src/main/java/in/testpress/samples/core/TestpressCoreSampleActivity.java @@ -119,8 +119,8 @@ public void onClick(View view) { } private void authenticate(String userId, String accessToken, TestpressSdk.Provider provider) { - InstituteSettings instituteSettings = new InstituteSettings("https://sandbox.testpress.in"); - instituteSettings.setWhiteLabeledHostUrl("https://sandbox.testpress.in"); + InstituteSettings instituteSettings = new InstituteSettings("https://staging.testpress.in"); + instituteSettings.setWhiteLabeledHostUrl("https://staging.testpress.in"); instituteSettings.setAndroidSentryDns("https://35dcf0dbd28045628831e62dd959ae4b@sentry.testpress.in/5"); instituteSettings.setEnableOfflineExam(true); instituteSettings.setUseNewDiscountFeat(false);