/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ // ChapterWebView.kt package com.aryan.reader.epubreader import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.graphics.Color import android.graphics.Rect import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.core.net.toUri import com.aryan.reader.R import com.aryan.reader.getReaderTextureDataUri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.json.JSONObject import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader private const val TAG_LINK_NAV = "LINK_NAV" private val READER_WEB_VIEW_JS_INTERFACES = arrayOf( "PageInfoReporter", "ProgressReporter", "ContentBridge", "HighlightBridge", "AutoScrollBridge", "CfiBridge", "SnippetBridge", "TtsBridge", "AiBridge", "FootnoteBridge", "LinkNavBridge" ) private fun WebView.releaseReaderResources() { try { stopLoading() READER_WEB_VIEW_JS_INTERFACES.forEach { removeJavascriptInterface(it) } webChromeClient = null webViewClient = WebViewClient() loadDataWithBaseURL(null, "", "text/html", "UTF-8", null) clearHistory() removeAllViews() destroy() } catch (e: Exception) { Timber.w(e, "Failed to fully release EPUB WebView resources") } } private fun getFontCssInjection(): String { return """ @font-face { font-family: 'Merriweather'; src: url('file:///android_asset/fonts/merriweather.ttf'); } @font-face { font-family: 'Lato'; src: url('file:///android_asset/fonts/lato.ttf'); } @font-face { font-family: 'Lora'; src: url('file:///android_asset/fonts/lora.ttf'); } @font-face { font-family: 'Roboto Mono'; src: url('file:///android_asset/fonts/roboto_mono.ttf'); } @font-face { font-family: 'Lexend'; src: url('file:///android_asset/fonts/lexend.ttf'); } """.trimIndent() } private fun getJsToInject(context: Context): String { return try { context.assets.open("epub_reader.js").use { inputStream -> BufferedReader(InputStreamReader(inputStream)).use { reader -> reader.readText() } } } catch (e: Exception) { Timber.e(e, "Error reading epub_reader.js from assets") "" // Return empty string on error } } @Suppress("unused") class AutoScrollJsBridge( private val callback: () -> Unit ) { @JavascriptInterface fun onChapterEnd() { Timber.d("Bridge: onChapterEnd called from JavaScript. Invoking callback.") callback() } } @Suppress("unused") class TtsJsBridge( private val scope: CoroutineScope, private val ttsStructuredTextHandler: suspend (String) -> Unit ) { @JavascriptInterface fun onStructuredTextExtracted(json: String) { Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("JS Bridge received JSON. Length: ${json.length}") if (json.isNotBlank() && json != "[]") { scope.launch(kotlinx.coroutines.Dispatchers.Default) { ttsStructuredTextHandler(json) } } else { Timber.tag("TTS_CHAPTER_CHANGE_DIAG").w("JS Bridge received empty or blank JSON. This may trigger a chapter skip.") scope.launch(kotlinx.coroutines.Dispatchers.Default) { ttsStructuredTextHandler("[]") } } } } @Suppress("unused") class HighlightJsBridge( private val onCreateCallback: (String, String, String) -> Unit, // Renamed to avoid recursion private val onClickCallback: ((String, String, Int, Int, Int, Int) -> Unit)? = null // Renamed ) { @JavascriptInterface fun onHighlightCreated(cfi: String, text: String, colorId: String) { onCreateCallback(cfi, text, colorId) // Calls the lambda property } @JavascriptInterface fun onHighlightClicked( cfi: String, text: String, left: Int, top: Int, right: Int, bottom: Int ) { onClickCallback?.invoke(cfi, text, left, top, right, bottom) } } @Suppress("unused") class ContentBridge( private val onChunkRequested: (index: Int) -> Unit ) { @JavascriptInterface fun requestChunk(index: Int) { onChunkRequested(index) } } @Suppress("unused") class CfiJsBridge( private val onCfiReady: (String) -> Unit, private val onCfiForBookmarkReady: (String) -> Unit, private val onScrollFinishedCallback: (Boolean) -> Unit ) { @JavascriptInterface fun onCfiExtracted(jsonResponse: String) { try { // --- ADDED LOG --- Timber.tag("PosSaveDiag") .d("CfiJsBridge.onCfiExtracted: Raw JSON received from JS: $jsonResponse") val json = JSONObject(jsonResponse) val cfi = json.optString("cfi", "/4") val logArray = json.optJSONArray("log") Timber.d("--- Start CFI Save Diagnostics ---") Timber.d("Received CFI for saving: $cfi") if (logArray != null) { for (i in 0 until logArray.length()) { Timber.d(logArray.getString(i)) } } else { Timber.d("No log array received. Raw response: $jsonResponse") } Timber.d("--- End CFI Save Diagnostics ---") if (cfi.isNotBlank()) { onCfiReady(cfi) } } catch (e: Exception) { Timber.tag("PosSaveDiag") .e(e, "CfiJsBridge.onCfiExtracted: Error parsing CFI JSON response: $jsonResponse") onCfiReady("/4") } } @JavascriptInterface fun onCfiForBookmarkExtracted(jsonResponse: String) { try { val json = JSONObject(jsonResponse) val cfi = json.optString("cfi") val logArray = json.optJSONArray("log") Timber.d("--- Start CFI Diagnostics (Bookmark) ---") Timber.d("Received CFI for bookmark: $cfi") if (logArray != null) { for (i in 0 until logArray.length()) { Timber.d(logArray.getString(i)) } } else { Timber.d("No log array received. Raw response: $jsonResponse") } Timber.d("--- End CFI Diagnostics (Bookmark) ---") if (cfi != null) { onCfiForBookmarkReady(cfi) } } catch (e: Exception) { Timber.e(e, "Error parsing CFI JSON for bookmark: $jsonResponse") } } @JavascriptInterface fun onScrollFinished(success: Boolean) { Timber.tag("BookmarkDiagnosis").d("JS reported scroll finished. Success: $success") onScrollFinishedCallback(success) } } @Suppress("unused") class SnippetJsBridge( private val onSnippetReady: (String, String) -> Unit ) { @JavascriptInterface fun onSnippetExtracted(cfi: String, snippet: String) { Timber.d("SnippetJsBridge.onSnippetExtracted received. CFI: '$cfi', Snippet: '$snippet'") onSnippetReady(cfi, snippet) } } @Suppress("unused") class ProgressJsBridge( private val onTopChunkUpdated: (Int) -> Unit ) { private var lastReportedChunk = -1 @JavascriptInterface fun updateTopChunk(chunkIndex: Int) { if (chunkIndex != lastReportedChunk) { lastReportedChunk = chunkIndex onTopChunkUpdated(chunkIndex) } } } private data class CustomMenuState( val selectedText: String, val selectionBounds: Rect, val finishActionModeCallback: () -> Unit, val cfi: String? = null, val isExistingHighlight: Boolean = false, val note: String? = null, val selectedColor: HighlightColor? = null ) @Suppress("unused") class AiJsBridge( private val scope: CoroutineScope, private val onContentReady: suspend (String) -> Unit ) { @JavascriptInterface fun onContentExtractedForSummarization(text: String) { Timber.d("Content extracted for summarization, length: ${text.length}") if (text.isNotBlank()) { scope.launch { onContentReady(text) } } } } @Suppress("unused") class FootnoteJsBridge( private val onFootnoteRequestCallback: (String) -> Unit ) { @JavascriptInterface fun onFootnoteRequested(htmlContent: String) { Timber.tag("FootnoteDiag").d("Kotlin Bridge received footnote content. Length: ${htmlContent.length}") onFootnoteRequestCallback(htmlContent) } } @Suppress("unused") class LinkNavJsBridge( private val currentChapterTitle: String, private val onInternalLinkClick: (String) -> Unit ) { @JavascriptInterface fun onLinkClicked(href: String, epubType: String, linkText: String) { Timber.tag(TAG_LINK_NAV) .d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'") onInternalLinkClick(href) } } @SuppressLint("SetJavaScriptEnabled") @Composable fun ChapterWebView( key: Any, initialHtmlContent: String, baseUrl: String, totalChunks: Int, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, onHighlightDeleted: (String) -> Unit, onChunkRequested: (Int) -> Unit, chapterTitle: String, isDarkTheme: Boolean, effectiveBg: androidx.compose.ui.graphics.Color, effectiveText: androidx.compose.ui.graphics.Color, initialScrollTarget: ChapterScrollPosition?, initialPageScrollY: Int?, initialCfi: String?, initialChunkIndex: Int, onTopChunkUpdated: (Int) -> Unit, currentFontSize: Float, currentLineHeight: Float, currentParagraphGap: Float, currentImageSize: Float, currentHorizontalMargin: Float, currentVerticalMargin: Float, onChapterInitiallyScrolled: () -> Unit, modifier: Modifier = Modifier, onTap: () -> Unit, onPotentialScroll: () -> Unit, onOverScrollTop: (dragAmount: Float) -> Unit, onOverScrollBottom: (dragAmount: Float) -> Unit, onReleaseOverScrollTop: () -> Unit, onReleaseOverScrollBottom: () -> Unit, onScrollStateUpdate: (scrollY: Int, scrollHeight: Int, clientHeight: Int, activeFragmentId: String?) -> Unit, onWebViewInstanceCreated: (WebView) -> Unit, onCfiGenerated: (cfi: String) -> Unit, onBookmarkCfiGenerated: (cfi: String) -> Unit, onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit, onScrollFinished: (Boolean) -> Unit = {}, ttsScope: CoroutineScope, tocFragments: List, initialFragmentId: String? = null, onTtsTextReady: suspend (String) -> Unit, isProUser: Boolean, isOss: Boolean = false, onShowDictionaryUpsellDialog: () -> Unit, onWordSelectedForAiDefinition: (String) -> Unit, onTranslate: (String) -> Unit, onSearch: (String) -> Unit, onNoteRequested: (String?) -> Unit, onContentReadyForSummarization: suspend (String) -> Unit, onFootnoteRequested: (String) -> Unit, currentFontFamily: ReaderFont, customFontPath: String? = null, currentTextAlign: ReaderTextAlign, onHighlightClicked: () -> Unit, onAutoScrollChapterEnd: () -> Unit = {}, activeHighlightPalette: List, onUpdatePalette: (Int, HighlightColor) -> Unit, onInternalLinkClick: (String) -> Unit, onWebViewDisposed: (WebView) -> Unit = {}, activeTextureId: String? = null, activeTextureAlpha: Float = 0.55f ) { Timber.d( "RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget" ) var showExternalLinkDialog by remember { mutableStateOf(null) } val context = LocalContext.current val density = LocalDensity.current var localWebViewRef by remember { mutableStateOf(null) } var customMenuState by remember { mutableStateOf(null) } val jsToInject = remember(context) { getJsToInject(context) } var showPaletteManager by remember { mutableStateOf(false) } val latestUserHighlights by rememberUpdatedState(userHighlights) val textureBase64 by remember(activeTextureId) { mutableStateOf( getReaderTextureDataUri(context, activeTextureId) ) } val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady) val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated) val currentOnBookmarkCfiGenerated by rememberUpdatedState(onBookmarkCfiGenerated) val currentOnScrollFinished by rememberUpdatedState(onScrollFinished) LaunchedEffect(currentFontSize, currentLineHeight) { localWebViewRef?.evaluateJavascript( "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null ) } val highlightsJson = remember(userHighlights) { val jsonArray = org.json.JSONArray() userHighlights.forEach { h -> val obj = JSONObject() obj.put("cfi", h.cfi) obj.put("text", h.text) obj.put("cssClass", h.color.cssClass) jsonArray.put(obj) } jsonArray.toString() } if (showExternalLinkDialog != null) { val urlToShow = showExternalLinkDialog!! AlertDialog( onDismissRequest = { showExternalLinkDialog = null }, title = { Text(stringResource(R.string.dialog_external_link_title)) }, text = { Text(stringResource(R.string.dialog_external_link_desc, urlToShow)) }, confirmButton = { Row(horizontalArrangement = Arrangement.End) { TextButton(onClick = { val intent = Intent(Intent.ACTION_VIEW, urlToShow.toUri()) try { context.startActivity(intent) } catch (e: ActivityNotFoundException) { Timber.e(e, "No activity found to handle intent for URL: $urlToShow") Toast.makeText(context, context.getString(R.string.error_no_browser), Toast.LENGTH_LONG).show() } showExternalLinkDialog = null }) { Text(stringResource(R.string.action_open)) } TextButton(onClick = { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow) clipboard.setPrimaryClip(clip) showExternalLinkDialog = null }) { Text(stringResource(R.string.action_copy)) } } }, dismissButton = { TextButton(onClick = { showExternalLinkDialog = null }) { Text(stringResource(R.string.action_cancel)) } }) } Box(modifier = modifier.fillMaxSize()) { LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64, activeTextureAlpha) { val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb())) val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb())) localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});", null) } key( key, currentFontSize, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentTextAlign ) { AndroidView( factory = { ctx -> Timber.d( "InteractiveWebView factory for $chapterTitle (Key: $key), isDarkTheme: $isDarkTheme, initialScroll: $initialScrollTarget" ) @Suppress("unused") val webView = InteractiveWebView( context = ctx, onSingleTap = onTap, onPotentialScroll = onPotentialScroll, onOverScrollTop = onOverScrollTop, onOverScrollBottom = onOverScrollBottom, onReleaseOverScrollTop = onReleaseOverScrollTop, onReleaseOverScrollBottom = onReleaseOverScrollBottom, onShowCustomSelectionMenu = { text, bounds, finishCallback -> if (text.isNotBlank() && !bounds.isEmpty) { customMenuState = CustomMenuState( selectedText = text, selectionBounds = Rect(bounds), finishActionModeCallback = finishCallback, isExistingHighlight = false ) } else { customMenuState = null finishCallback() } }, onHideCustomSelectionMenu = { if (customMenuState?.isExistingHighlight != true) { customMenuState = null } }).apply { localWebViewRef = this onWebViewInstanceCreated(this) addJavascriptInterface( PageInfoBridge { scrollY, scrollHeight, clientHeight, activeFragmentId -> this.post { onScrollStateUpdate(scrollY, scrollHeight, clientHeight, activeFragmentId) } }, "PageInfoReporter" ) addJavascriptInterface( ProgressJsBridge { chunkIndex -> this.post { onTopChunkUpdated(chunkIndex) } }, "ProgressReporter" ) addJavascriptInterface(ContentBridge { index -> this.post { onChunkRequested(index) } }, "ContentBridge") addJavascriptInterface( HighlightJsBridge( onCreateCallback = { cfi, text, colorId -> this.post { onHighlightCreated(cfi, text, colorId) } }, onClickCallback = { cfi, text, left, top, right, bottom -> this.post { onHighlightClicked() localWebViewRef?.evaluateJavascript( "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null ) onNoteRequested(cfi) } } ), "HighlightBridge" ) addJavascriptInterface( AutoScrollJsBridge { this.post { onAutoScrollChapterEnd() } }, "AutoScrollBridge" ) webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { consoleMessage?.let { val message = it.message() when { message.startsWith("LINK_NAV:") -> { Timber.tag(TAG_LINK_NAV) .d("JS -> ${message.substringAfter("LINK_NAV: ")}") } message.startsWith("FootnoteDiag:") -> { Timber.tag("FootnoteDiag") .d("JS -> ${message.substringAfter("FootnoteDiag: ")}") } message.startsWith("TTS_CHAPTER_CHANGE_DIAG:") -> { Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("JS -> ${message.substringAfter("TTS_CHAPTER_CHANGE_DIAG: ")}") } message.startsWith("BookmarkDiagnosis") -> { Timber.tag("BookmarkDiagnosis") .d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}") } message.startsWith("TTS_LIST_DIAG:") -> { Timber.tag("TTS_LIST_DIAG").d("JS -> ${message.substringAfter("TTS_LIST_DIAG: ")}") } message.startsWith("CFI_DIAGNOSIS:") -> { Timber.d( "JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}" ) } message.startsWith("ImageDiagnosis") -> { Timber.d("JS -> $message") } message.startsWith("TTS_HIGHLIGHT_DIAGNOSIS:") -> { Timber.d( "JS -> ${message.substringAfter("TTS_HIGHLIGHT_DIAGNOSIS: ")}" ) } message.startsWith("PosSaveDiag:") -> { Timber.tag("PosSaveDiag") .d("JS -> ${message.substringAfter("PosSaveDiag: ")}") } message.startsWith("HIGHLIGHT_DEBUG:") -> { Timber.d( "JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}" ) } message.startsWith("ReaderFontDiagnosis") -> { Timber.d( "JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}" ) } message.startsWith("NavDiag:") -> { Timber.tag("NavDiag") .d("JS -> ${message.substringAfter("NavDiag: ")}") } message.startsWith("AutoScrollDiagnosis") -> { Timber.d( "JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}" ) } message.startsWith("FRAG_NAV_DEBUG") -> { Timber.tag("FRAG_NAV_DEBUG") .d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}") } else -> { Timber.d( "[${it.sourceId()}:${it.lineNumber()}] ${it.message()}" ) } } } return true } } addJavascriptInterface( CfiJsBridge( onCfiReady = { cfi -> this.post { currentOnCfiGenerated(cfi) } }, onCfiForBookmarkReady = { cfi -> this.post { currentOnBookmarkCfiGenerated(cfi) } }, onScrollFinishedCallback = { success -> this.post { currentOnScrollFinished(success) } } ), "CfiBridge" ) addJavascriptInterface( SnippetJsBridge { cfi, snippet -> this.post { currentOnSnippetForBookmarkReady(cfi, snippet) } }, "SnippetBridge" ) addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge") addJavascriptInterface( AiJsBridge(ttsScope, onContentReadyForSummarization), "AiBridge" ) addJavascriptInterface( FootnoteJsBridge { html -> this.post { onFootnoteRequested(html) } }, "FootnoteBridge" ) addJavascriptInterface( LinkNavJsBridge(chapterTitle) { href -> this.post { onInternalLinkClick(href) } }, "LinkNavBridge" ) webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? ): Boolean { val url = request?.url?.toString() if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) { Timber.tag(TAG_LINK_NAV) .d("[EXTERNAL-INTERCEPT] url='$url' from chapter '$chapterTitle'") showExternalLinkDialog = url return true } if (url != null && url.startsWith("file://")) { Timber.tag(TAG_LINK_NAV) .d("[INTERNAL-LINK-INTERCEPTED] url='$url' from chapter '$chapterTitle'") onInternalLinkClick(url) return true } if (url != null) { Timber.tag(TAG_LINK_NAV) .d("[INTERNAL-LINK-PASSED] url='$url' from chapter '$chapterTitle' — allowing WebView to handle") } return false } override fun onLoadResource(view: WebView?, url: String?) { super.onLoadResource(view, url) if (url?.contains(".jpg", true) == true || url?.contains( ".jpeg", true ) == true || url?.contains( ".png", true ) == true || url?.contains( ".gif", true ) == true || url?.contains( ".svg", true ) == true || url?.contains("image", true) == true ) { Timber.d( "WebView is attempting to load resource: $url" ) } } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) Timber.d( "onPageFinished. Injecting CSS and Font: ${currentFontFamily.fontFamilyName}" ) view?.evaluateJavascript(jsToInject, null) val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb())) val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb())) view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});", null) val fragmentsJson = org.json.JSONArray(tocFragments).toString() Timber.tag("FRAG_NAV_DEBUG") .d("onPageFinished: Re-injecting TOC_FRAGMENTS: $fragmentsJson") view?.evaluateJavascript( "javascript:window.TOC_FRAGMENTS = $fragmentsJson;", null ) view?.evaluateJavascript( "javascript:setTimeout(window.auditTocFragments, 500);", null ) view?.evaluateJavascript( """ javascript:(function() { if (window.__readerInternalLinkBridgeInstalled) return; window.__readerInternalLinkBridgeInstalled = true; document.addEventListener('click', function(event) { var target = event.target; var anchor = target && target.closest ? target.closest('a[href]') : null; if (!anchor && target && target.parentElement && target.parentElement.closest) { anchor = target.parentElement.closest('a[href]'); } if (!anchor) return; var rawHref = anchor.getAttribute('href') || ''; if (!rawHref) return; if (/^(https?:|mailto:|tel:|javascript:)/i.test(rawHref)) return; if (/^\/\//.test(rawHref)) return; event.preventDefault(); var resolvedHref = anchor.href || rawHref; if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) { window.LinkNavBridge.onLinkClicked( resolvedHref, anchor.getAttribute('epub:type') || '', anchor.textContent || '' ); } }, true); })(); """.trimIndent(), null ) view?.evaluateJavascript( "javascript:window.HighlightBridgeHelper.restoreHighlights('${ escapeJsString( highlightsJson ) }');", null ) val fontCss = getFontCssInjection().replace("\n", " ") val customFontCss = if (customFontPath != null) { "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" } else "" val combinedCss = "$fontCss $customFontCss" val injectFontJs = "var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);" view?.evaluateJavascript("javascript:$injectFontJs") { Timber.d("CSS Injection result: $it") } val fontNameForJs = if (customFontPath != null) { "CustomFont" } else if (currentFontFamily == ReaderFont.ORIGINAL) { "" } else { currentFontFamily.fontFamilyName } view?.evaluateJavascript( "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);", null ) view?.evaluateJavascript( "javascript:window.checkImagesForDiagnosis();", null ) view?.evaluateJavascript( "javascript:window.virtualization.init($initialChunkIndex, $totalChunks);", null ) @Suppress("VariableNeverRead") var scrollActionTaken = false if (!initialCfi.isNullOrBlank()) { val cfiJsCommand = "javascript:window.scrollToCfi('$initialCfi');" Timber.tag("NavDiag").d("WebView onPageFinished: Triggering scroll to initialCfi: $initialCfi") view?.evaluateJavascript(cfiJsCommand) { onChapterInitiallyScrolled() scrollActionTaken = true } } else if (!initialFragmentId.isNullOrBlank()) { Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId") val js = """ (function() { var targetId = '$initialFragmentId'; var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); return -2; } if (window.virtualization && window.virtualization.chunksData) { for (var i = 0; i < window.virtualization.chunksData.length; i++) { var chunkHtml = window.virtualization.chunksData[i]; if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) { return i; } } } return -1; })() """.trimIndent() view?.evaluateJavascript(js) { result -> val chunkIdx = result?.toIntOrNull() ?: -1 if (chunkIdx >= 0) { for (i in 0..chunkIdx) { onChunkRequested(i) } val scrollJs = """ (function() { var chunkIndex = $chunkIdx; var fragmentId = '$initialFragmentId'; setTimeout(function() { var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); if (chunkDiv && chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; chunkDiv.style.height = ""; } setTimeout(function() { var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]'); if (el) { var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } else if (chunkDiv) { var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP; window.scrollTo({ top: targetScrollY, behavior: 'auto' }); } }, 50); }, 200); })() """.trimIndent() view.evaluateJavascript(scrollJs, null) } } onChapterInitiallyScrolled() scrollActionTaken = true } else if (initialScrollTarget != null) { val scrollJsCommand = when (initialScrollTarget) { ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();" else -> "javascript:window.scrollToChapterStart();" } Timber.tag("NavDiag").d("WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget") view?.evaluateJavascript(scrollJsCommand) { onChapterInitiallyScrolled() scrollActionTaken = true } } else if (initialPageScrollY != null && initialPageScrollY > 0) { val scrollJsCommand = "javascript:window.scrollToSpecificY($initialPageScrollY);" Timber.tag("NavDiag").d("WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY") view?.evaluateJavascript(scrollJsCommand) { onChapterInitiallyScrolled() scrollActionTaken = true } } else { Timber.tag("NavDiag").d("WebView onPageFinished: No specific scroll, defaulting to start.") view?.evaluateJavascript("javascript:window.scrollToChapterStart();") { onChapterInitiallyScrolled() scrollActionTaken = true } } view?.clearFocus() view?.evaluateJavascript( "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null ) } } settings.apply { javaScriptEnabled = true allowFileAccess = true allowContentAccess = true domStorageEnabled = true layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL setNeedInitialFocus(false) setSupportZoom(false) builtInZoomControls = false displayZoomControls = false useWideViewPort = true loadWithOverviewMode = true } isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false this.setBackgroundColor(Color.TRANSPARENT) Timber.d( "WebView loading initial data with base URL: $baseUrl (Key: $key)" ) loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null) } webView }, modifier = Modifier.fillMaxSize(), onRelease = { releasedWebView -> if (localWebViewRef === releasedWebView) { localWebViewRef = null } customMenuState?.finishActionModeCallback?.invoke() customMenuState = null onWebViewDisposed(releasedWebView) releasedWebView.releaseReaderResources() }, update = { webView -> Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}") localWebViewRef = webView onWebViewInstanceCreated(webView) val fontCss = getFontCssInjection().replace("\n", " ") val customFontCss = if (customFontPath != null) { "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" } else "" val combinedCss = "$fontCss $customFontCss" val injectFontJs = "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";" webView.evaluateJavascript("javascript:$injectFontJs", null) val fontNameForJs = if (customFontPath != null) { "CustomFont" } else if (currentFontFamily == ReaderFont.ORIGINAL) { "" } else { currentFontFamily.fontFamilyName } val fragmentsJson = org.json.JSONArray(tocFragments).toString() Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson") webView.evaluateJavascript( "javascript:window.setTocFragments($fragmentsJson);", null ) webView.evaluateJavascript( "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);", null ) val escapedHighlights = escapeJsString(highlightsJson) webView.evaluateJavascript( "javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", null ) } ) } // Custom Selection Menu Popup customMenuState?.let { state -> val popupPositionProvider = remember(state.selectionBounds, density, state.isExistingHighlight) { object : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize ): IntOffset { val topMargin = with(density) { 16.dp.toPx() }.toInt() val bottomMargin = with(density) { if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx() }.toInt() var x = state.selectionBounds.centerX() - popupContentSize.width / 2 var y = state.selectionBounds.top - popupContentSize.height - topMargin if (y < with(density) { 24.dp.toPx() }.toInt()) { y = state.selectionBounds.bottom + bottomMargin } if (x < 0) x = 0 if (x + popupContentSize.width > windowSize.width) { x = windowSize.width - popupContentSize.width } if (y + popupContentSize.height > windowSize.height) { y = windowSize.height - popupContentSize.height } if (y < 0) y = 0 return IntOffset( x.coerceIn(0, windowSize.width - popupContentSize.width), y.coerceIn(0, windowSize.height - popupContentSize.height) ) } } } Popup( popupPositionProvider = popupPositionProvider, onDismissRequest = { state.finishActionModeCallback() customMenuState = null }) { Surface( shape = RoundedCornerShape(12.dp), shadowElevation = 6.dp, color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column( modifier = Modifier.width(IntrinsicSize.Max) ) { Row( modifier = Modifier .padding(vertical = 12.dp, horizontal = 12.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { activeHighlightPalette.forEachIndexed { index, colorEnum -> Box( modifier = Modifier .padding(horizontal = 6.dp) .size(32.dp) .background(colorEnum.color, CircleShape) .pointerInput(colorEnum) { detectTapGestures(onTap = { Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}") if (state.isExistingHighlight && state.cfi != null) { localWebViewRef?.evaluateJavascript( "javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');", null ) } else { localWebViewRef?.evaluateJavascript( "javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');", null ) } state.finishActionModeCallback() localWebViewRef?.clearFocus() customMenuState = null }, onLongPress = { showPaletteManager = true }) }) } Spacer(modifier = Modifier.width(8.dp)) SpectrumButton( onClick = { showPaletteManager = true }, size = 32.dp ) } HorizontalDivider() Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { PaginatedTextSelectionMenu( onCopy = { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), state.selectedText) clipboard.setPrimaryClip(clip) state.finishActionModeCallback() localWebViewRef?.clearFocus() localWebViewRef?.evaluateJavascript( "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null ) customMenuState = null }, onSelectAll = null, onDictionary = { val textToDefine = state.selectedText if (textToDefine.isNotBlank()) { onWordSelectedForAiDefinition(textToDefine) } customMenuState = null }, onTranslate = { val textToDefine = state.selectedText if (textToDefine.isNotBlank()) { onTranslate(textToDefine) } customMenuState = null }, onSearch = { val textToDefine = state.selectedText if (textToDefine.isNotBlank()) { onSearch(textToDefine) } customMenuState = null }, onNote = { if (state.isExistingHighlight && state.cfi != null) { onNoteRequested(state.cfi) } else { onNoteRequested(null) localWebViewRef?.evaluateJavascript( "javascript:window.HighlightBridgeHelper.createUserHighlight('${HighlightColor.YELLOW.cssClass}', '${HighlightColor.YELLOW.id}');", null ) } state.finishActionModeCallback() customMenuState = null }, onHighlight = null, onTts = { localWebViewRef?.evaluateJavascript( "javascript:window.TtsBridgeHelper.extractAndRelayTextFromSelection();", null ) state.finishActionModeCallback() localWebViewRef?.clearFocus() localWebViewRef?.evaluateJavascript( "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null ) customMenuState = null }, onDelete = if (state.isExistingHighlight && state.cfi != null) { { val highlightToDelete = userHighlights.find { h -> h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi) } if (highlightToDelete != null) { val cssClassToDelete = highlightToDelete.color.cssClass localWebViewRef?.evaluateJavascript( "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(highlightToDelete.cfi)}', '$cssClassToDelete');", null ) onHighlightDeleted(highlightToDelete.cfi) } state.finishActionModeCallback() customMenuState = null } } else null, isProUser = isProUser, isOss = isOss, existingNote = state.note, selectedColor = state.selectedColor ) } } } } } if (showPaletteManager) { PaletteManagerDialog( currentPalette = activeHighlightPalette, onDismiss = { showPaletteManager = false }, onSave = { newPalette -> newPalette.forEachIndexed { index, color -> onUpdatePalette(index, color) } showPaletteManager = false }) } } }