Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
965
app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
Normal file
965
app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
Normal file
|
|
@ -0,0 +1,965 @@
|
|||
// 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 timber.log.Timber
|
||||
import android.webkit.JavascriptInterface
|
||||
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.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CopyAll
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.countWords
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
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") // function used by JavaScript
|
||||
class TtsJsBridge(
|
||||
private val scope: CoroutineScope,
|
||||
private val ttsStructuredTextHandler: suspend (String) -> Unit
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun onStructuredTextExtracted(json: String) {
|
||||
if (json.isNotBlank() && json != "[]") {
|
||||
scope.launch {
|
||||
ttsStructuredTextHandler(json)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
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
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun onCfiExtracted(jsonResponse: String) {
|
||||
// This is called from JavaScript with the generated CFI and diagnostics
|
||||
try {
|
||||
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.e(e, "Error parsing CFI JSON response: $jsonResponse")
|
||||
// Still call back with a fallback CFI so the app doesn't hang
|
||||
onCfiReady("/4")
|
||||
}
|
||||
}
|
||||
@JavascriptInterface
|
||||
fun onCfiForBookmarkExtracted(jsonResponse: String) {
|
||||
// This is called from JavaScript with the generated CFI for a bookmark action
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
initialScrollTarget: ChapterScrollPosition?,
|
||||
initialPageScrollY: Int?,
|
||||
initialCfi: String?,
|
||||
initialChunkIndex: Int,
|
||||
onTopChunkUpdated: (Int) -> Unit,
|
||||
currentFontSize: Float,
|
||||
currentLineHeight: Float,
|
||||
onChapterInitiallyScrolled: () -> Unit,
|
||||
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,
|
||||
ttsScope: CoroutineScope,
|
||||
tocFragments: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
initialFragmentId: String? = null,
|
||||
onTtsTextReady: suspend (String) -> Unit,
|
||||
isProUser: Boolean,
|
||||
isOss: Boolean = false,
|
||||
onShowDictionaryUpsellDialog: () -> Unit,
|
||||
onWordSelectedForAiDefinition: (String) -> Unit,
|
||||
onContentReadyForSummarization: suspend (String) -> Unit,
|
||||
currentFontFamily: ReaderFont,
|
||||
customFontPath: String? = null,
|
||||
currentTextAlign: ReaderTextAlign,
|
||||
onHighlightClicked: () -> Unit,
|
||||
onAutoScrollChapterEnd: () -> Unit = {},
|
||||
) {
|
||||
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) }
|
||||
|
||||
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("External Link") },
|
||||
text = { Text("You clicked on an external link:\n\n$urlToShow\n\nWhat would you like to do?") },
|
||||
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,
|
||||
"No browser found to open the link.",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
showExternalLinkDialog = null
|
||||
}) { Text("Open") }
|
||||
TextButton(onClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Copied Link", urlToShow)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
showExternalLinkDialog = null
|
||||
}) { Text("Copy") }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExternalLinkDialog = null }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
key(
|
||||
key,
|
||||
isDarkTheme,
|
||||
currentFontSize,
|
||||
currentLineHeight,
|
||||
currentFontFamily,
|
||||
currentTextAlign
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
Timber.d(
|
||||
"InteractiveWebView factory for $chapterTitle (Key: $key), isDarkTheme: $isDarkTheme, initialScroll: $initialScrollTarget"
|
||||
)
|
||||
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(onScrollStateUpdate),
|
||||
"PageInfoReporter"
|
||||
)
|
||||
addJavascriptInterface(
|
||||
ProgressJsBridge(onTopChunkUpdated),
|
||||
"ProgressReporter"
|
||||
)
|
||||
addJavascriptInterface(ContentBridge(onChunkRequested), "ContentBridge")
|
||||
|
||||
addJavascriptInterface(HighlightJsBridge(
|
||||
onCreateCallback = onHighlightCreated,
|
||||
onClickCallback = { cfi, text, left, top, right, bottom ->
|
||||
|
||||
onHighlightClicked()
|
||||
|
||||
val densityValue = density.density
|
||||
val locationOnScreen = IntArray(2)
|
||||
this.getLocationOnScreen(locationOnScreen)
|
||||
val xOffset = locationOnScreen[0]
|
||||
val yOffset = locationOnScreen[1]
|
||||
|
||||
val rect = Rect(
|
||||
(left * densityValue).toInt() + xOffset,
|
||||
(top * densityValue).toInt() + yOffset,
|
||||
(right * densityValue).toInt() + xOffset,
|
||||
(bottom * densityValue).toInt() + yOffset
|
||||
)
|
||||
|
||||
customMenuState = CustomMenuState(
|
||||
selectedText = text,
|
||||
selectionBounds = rect,
|
||||
finishActionModeCallback = {
|
||||
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
|
||||
},
|
||||
cfi = cfi,
|
||||
isExistingHighlight = true
|
||||
)
|
||||
}
|
||||
), "HighlightBridge")
|
||||
|
||||
addJavascriptInterface(
|
||||
AutoScrollJsBridge {
|
||||
onAutoScrollChapterEnd()
|
||||
},
|
||||
"AutoScrollBridge"
|
||||
)
|
||||
|
||||
webChromeClient = object : android.webkit.WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
|
||||
consoleMessage?.let {
|
||||
val message = it.message()
|
||||
when {
|
||||
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("HIGHLIGHT_DEBUG:") -> {
|
||||
Timber.d(
|
||||
"JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}"
|
||||
)
|
||||
}
|
||||
|
||||
message.startsWith("ReaderFontDiagnosis") -> {
|
||||
Timber.d(
|
||||
"JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}"
|
||||
)
|
||||
}
|
||||
|
||||
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 -> onCfiGenerated(cfi) },
|
||||
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) }
|
||||
), "CfiBridge")
|
||||
addJavascriptInterface(SnippetJsBridge { cfi, snippet ->
|
||||
onSnippetForBookmarkReady(
|
||||
cfi,
|
||||
snippet
|
||||
)
|
||||
}, "SnippetBridge")
|
||||
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")
|
||||
addJavascriptInterface(
|
||||
AiJsBridge(ttsScope, onContentReadyForSummarization),
|
||||
"AiBridge"
|
||||
)
|
||||
|
||||
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.d("Intercepted external link: $url")
|
||||
showExternalLinkDialog = url
|
||||
return true
|
||||
}
|
||||
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)
|
||||
view?.evaluateJavascript(
|
||||
"javascript:window.applyReaderTheme($isDarkTheme);",
|
||||
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: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}');",
|
||||
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.d(
|
||||
"WebView onPageFinished: Executing initial scroll to CFI: $initialCfi"
|
||||
)
|
||||
view?.evaluateJavascript(cfiJsCommand) {
|
||||
onChapterInitiallyScrolled()
|
||||
scrollActionTaken = true
|
||||
}
|
||||
} else if (!initialFragmentId.isNullOrBlank()) {
|
||||
Timber.d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
|
||||
view?.evaluateJavascript(
|
||||
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }",
|
||||
null
|
||||
)
|
||||
onChapterInitiallyScrolled()
|
||||
scrollActionTaken = true
|
||||
} else if (initialScrollTarget != null) {
|
||||
val scrollJsCommand = when (initialScrollTarget) {
|
||||
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"
|
||||
else -> "javascript:window.scrollToChapterStart();"
|
||||
}
|
||||
Timber.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.d(
|
||||
"WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY"
|
||||
)
|
||||
view?.evaluateJavascript(scrollJsCommand) {
|
||||
onChapterInitiallyScrolled()
|
||||
scrollActionTaken = true
|
||||
}
|
||||
} else {
|
||||
Timber.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
|
||||
},
|
||||
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}');",
|
||||
null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
) {
|
||||
// 1. Color Row (Improved sizing and gaps)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center, // Centered colors
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HighlightColor.entries.forEach { colorEnum ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(24.dp)
|
||||
.background(colorEnum.color, CircleShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
|
||||
.clickable {
|
||||
Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}")
|
||||
|
||||
if (state.isExistingHighlight && state.cfi != null) {
|
||||
// UPDATE EXISTING HIGHLIGHT
|
||||
Timber.d("Kotlin: Requesting UPDATE via JS for CFI: ${state.cfi}")
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');",
|
||||
null
|
||||
)
|
||||
} else {
|
||||
// CREATE NEW HIGHLIGHT
|
||||
Timber.d("Kotlin: Requesting CREATE via JS")
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');",
|
||||
null
|
||||
)
|
||||
}
|
||||
state.finishActionModeCallback()
|
||||
localWebViewRef?.clearFocus()
|
||||
customMenuState = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete Option (Only for existing highlights)
|
||||
if (state.isExistingHighlight && state.cfi != null) {
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
// LOGGING START
|
||||
Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'")
|
||||
|
||||
// 1. IMPROVED LOOKUP: Check if the clicked CFI exists within any split CFI string
|
||||
val highlightToDelete = userHighlights.find { h ->
|
||||
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
|
||||
}
|
||||
|
||||
if (highlightToDelete == null) {
|
||||
Timber.e("Kotlin: ERROR - Lookup failed. CFI '${state.cfi}' not found in any highlight.")
|
||||
} else {
|
||||
Timber.d("Kotlin: SUCCESS - Found highlight object. Full CFI: '${highlightToDelete.cfi}', Color: ${highlightToDelete.color.id}")
|
||||
|
||||
val cssClassToDelete = highlightToDelete.color.cssClass
|
||||
val allCfiParts = highlightToDelete.cfi.split("|")
|
||||
|
||||
allCfiParts.forEach { partCfi ->
|
||||
Timber.d("Kotlin: Requesting JS removal for part: '$partCfi'")
|
||||
localWebViewRef?.evaluateJavascript(
|
||||
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
onHighlightDeleted(highlightToDelete.cfi)
|
||||
}
|
||||
|
||||
state.finishActionModeCallback()
|
||||
customMenuState = null
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Remove",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Remove",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// 2. Copy Option
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Copied Text", state.selectedText)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
state.finishActionModeCallback()
|
||||
localWebViewRef?.clearFocus()
|
||||
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
|
||||
customMenuState = null
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CopyAll,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Copy",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Dictionary Option (Preserving Logic)
|
||||
if (!isOss && state.selectedText.length <= 2000) {
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val textToDefine = state.selectedText
|
||||
if (textToDefine.isNotBlank()) {
|
||||
val wordCount = countWords(textToDefine)
|
||||
if (isProUser || wordCount <= 1) {
|
||||
onWordSelectedForAiDefinition(textToDefine)
|
||||
} else {
|
||||
onShowDictionaryUpsellDialog()
|
||||
}
|
||||
}
|
||||
customMenuState = null
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.dictionary),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Dictionary",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue