book-reader/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
Aryan b20ade9946
Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
2026-05-15 22:36:51 +05:30

1230 lines
59 KiB
Kotlin

/*
* 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 <https://www.gnu.org/licenses/>.
*
* 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<UserHighlight>,
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<String>,
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<HighlightColor>,
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<String?>(null) }
val context = LocalContext.current
val density = LocalDensity.current
var localWebViewRef by remember { mutableStateOf<WebView?>(null) }
var customMenuState by remember { mutableStateOf<CustomMenuState?>(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
})
}
}
}