/* * 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.rememberScrollState import androidx.compose.foundation.verticalScroll 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.heightIn 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.LocalConfiguration 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 com.aryan.reader.shared.ui.SharedSelectionMenuRect import com.aryan.reader.shared.ui.SharedSelectionMenuSize import com.aryan.reader.shared.ui.SharedSelectionMenuViewport import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement 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 const val TAG_VERTICAL_JITTER = "EpubVerticalJitter" private val READER_WEB_VIEW_JS_INTERFACES = arrayOf( "PageInfoReporter", "ProgressReporter", "ContentBridge", "HighlightBridge", "AutoScrollBridge", "CfiBridge", "SnippetBridge", "TtsBridge", "AiBridge", "FootnoteBridge", "LinkNavBridge" ) private class WebViewRuntimeApplierState { var fontCss: String? = null var styleSignature: String? = null var tocFragmentsJson: String? = null var highlightsJson: String? = null private var unchangedUpdateCount = 0 private var pendingUpdateCount = 0 private var lastUnchangedUpdateLogAt = 0L private var lastPendingUpdateLogAt = 0L fun logApplied( chapterTitle: String, fontCssChanged: Boolean, styleChanged: Boolean, tocFragmentsChanged: Boolean, highlightsChanged: Boolean ) { if (unchangedUpdateCount > 0) { Timber.tag(TAG_VERTICAL_JITTER).d( "androidUpdate resumeAfterUnchanged count=$unchangedUpdateCount chapter='$chapterTitle'" ) unchangedUpdateCount = 0 } if (pendingUpdateCount > 0) { Timber.tag(TAG_VERTICAL_JITTER).d( "androidUpdate resumeAfterPending count=$pendingUpdateCount chapter='$chapterTitle'" ) pendingUpdateCount = 0 } lastUnchangedUpdateLogAt = System.currentTimeMillis() lastPendingUpdateLogAt = lastUnchangedUpdateLogAt Timber.tag(TAG_VERTICAL_JITTER).d( "androidUpdate applied chapter='$chapterTitle' fontCss=$fontCssChanged " + "style=$styleChanged toc=$tocFragmentsChanged highlights=$highlightsChanged" ) } fun logUnchanged(chapterTitle: String) { unchangedUpdateCount++ val now = System.currentTimeMillis() if (now - lastUnchangedUpdateLogAt >= 1000L) { Timber.tag(TAG_VERTICAL_JITTER).d( "androidUpdate unchanged count=$unchangedUpdateCount chapter='$chapterTitle'" ) unchangedUpdateCount = 0 lastUnchangedUpdateLogAt = now } } fun logPending(chapterTitle: String) { pendingUpdateCount++ val now = System.currentTimeMillis() if (now - lastPendingUpdateLogAt >= 1000L) { Timber.tag(TAG_VERTICAL_JITTER).d( "androidUpdate pendingPageLoad count=$pendingUpdateCount chapter='$chapterTitle'" ) pendingUpdateCount = 0 lastPendingUpdateLogAt = now } } } 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 ) { val runtimeApplierState = remember { WebViewRuntimeApplierState() } var isReaderRuntimeReady by remember { mutableStateOf(false) } 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) val debugWebViewId = System.identityHashCode(this).toString(16) 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: ")}") } message.startsWith("$TAG_VERTICAL_JITTER:") -> { Timber.tag(TAG_VERTICAL_JITTER) .d("webView=$debugWebViewId chapter='$chapterTitle' JS -> ${message.substringAfter("$TAG_VERTICAL_JITTER: ")}") } 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.CURRENT_HIGHLIGHTS = '${ escapeJsString( highlightsJson ) }'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", 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 } runtimeApplierState.fontCss = combinedCss runtimeApplierState.styleSignature = listOf( currentFontSize, currentLineHeight, fontNameForJs, currentTextAlign.cssValue, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin ).joinToString(separator = "|") runtimeApplierState.tocFragmentsJson = fragmentsJson runtimeApplierState.highlightsJson = highlightsJson 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 ) isReaderRuntimeReady = true Timber.tag(TAG_VERTICAL_JITTER) .d("androidPageFinished runtimeReady webView=$debugWebViewId chapter='$chapterTitle'") } } 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 -> isReaderRuntimeReady = false if (localWebViewRef === releasedWebView) { localWebViewRef = null } customMenuState?.finishActionModeCallback?.invoke() customMenuState = null onWebViewDisposed(releasedWebView) releasedWebView.releaseReaderResources() }, update = { webView -> localWebViewRef = webView onWebViewInstanceCreated(webView) if (!isReaderRuntimeReady) { runtimeApplierState.logPending(chapterTitle) } else { 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 fontNameForJs = if (customFontPath != null) { "CustomFont" } else if (currentFontFamily == ReaderFont.ORIGINAL) { "" } else { currentFontFamily.fontFamilyName } val fragmentsJson = org.json.JSONArray(tocFragments).toString() val styleSignature = listOf( currentFontSize, currentLineHeight, fontNameForJs, currentTextAlign.cssValue, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin ).joinToString(separator = "|") val fontCssChanged = runtimeApplierState.fontCss != combinedCss val styleChanged = runtimeApplierState.styleSignature != styleSignature val tocFragmentsChanged = runtimeApplierState.tocFragmentsJson != fragmentsJson val highlightsChanged = runtimeApplierState.highlightsJson != highlightsJson if (fontCssChanged || styleChanged || tocFragmentsChanged || highlightsChanged) { runtimeApplierState.logApplied( chapterTitle = chapterTitle, fontCssChanged = fontCssChanged, styleChanged = styleChanged, tocFragmentsChanged = tocFragmentsChanged, highlightsChanged = highlightsChanged ) } else { runtimeApplierState.logUnchanged(chapterTitle) } if (fontCssChanged) { runtimeApplierState.fontCss = combinedCss 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) } if (tocFragmentsChanged) { runtimeApplierState.tocFragmentsJson = fragmentsJson Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson") webView.evaluateJavascript( "javascript:window.setTocFragments($fragmentsJson);", null ) } if (styleChanged) { runtimeApplierState.styleSignature = styleSignature webView.evaluateJavascript( "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);", null ) } if (highlightsChanged) { runtimeApplierState.highlightsJson = highlightsJson 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 configuration = LocalConfiguration.current val selectionMenuMaxHeight = (configuration.screenHeightDp.dp - 32.dp).coerceAtLeast(160.dp) val popupPositionProvider = remember(state.selectionBounds, density, state.isExistingHighlight) { object : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize ): IntOffset { val marginPx = with(density) { 16.dp.toPx() } val gapPx = with(density) { if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx() } val placement = sharedSelectionMenuPlacement( viewport = SharedSelectionMenuViewport(windowSize.width, windowSize.height), popup = SharedSelectionMenuSize(popupContentSize.width, popupContentSize.height), selection = SharedSelectionMenuRect( left = state.selectionBounds.left.toFloat(), top = state.selectionBounds.top.toFloat(), right = state.selectionBounds.right.toFloat(), bottom = state.selectionBounds.bottom.toFloat() ), marginPx = marginPx, gapPx = gapPx ) return IntOffset(placement.x, placement.y) } } } 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) .heightIn(max = selectionMenuMaxHeight) .verticalScroll(rememberScrollState()) ) { Row( modifier = Modifier .padding(vertical = 8.dp, horizontal = 10.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { activeHighlightPalette.forEachIndexed { index, colorEnum -> Box( modifier = Modifier .padding(horizontal = 4.dp) .size(28.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 = 28.dp ) } HorizontalDivider() Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 6.dp, vertical = 6.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 }) } } }