General improvements (#316)

* Added support for right text alignment in epub reader

* Added tabs management to PDF navigation drawer

* Implemented unified selection menu placement logic

* Added reset functionality for toolbar customization

* Improved highlight filtering in epub pagination

* Migrated hardcoded UI strings to string resources

* Extracted desktop application logic from Main.kt into modular files

* Refactored Main.kt by extracting PDF and EPUB logic into specialized files

* Added Spanish language support

* Added system default option to app language selection
This commit is contained in:
Aryan 2026-05-16 16:19:31 +05:30 committed by GitHub
parent 759d4b73a0
commit 056485a140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 9184 additions and 5947 deletions

View file

@ -38,6 +38,8 @@ 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
@ -46,6 +48,7 @@ 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
@ -69,6 +72,7 @@ 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
@ -83,6 +87,10 @@ 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
@ -1026,6 +1034,8 @@ fun ChapterWebView(
// 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 {
@ -1035,30 +1045,23 @@ fun ChapterWebView(
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val topMargin = with(density) { 16.dp.toPx() }.toInt()
val bottomMargin = with(density) {
val marginPx = with(density) { 16.dp.toPx() }
val gapPx = 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)
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)
}
}
}
@ -1075,11 +1078,14 @@ fun ChapterWebView(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(
modifier = Modifier.width(IntrinsicSize.Max)
modifier = Modifier
.width(IntrinsicSize.Max)
.heightIn(max = selectionMenuMaxHeight)
.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
.padding(vertical = 12.dp, horizontal = 12.dp)
.padding(vertical = 8.dp, horizontal = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
@ -1087,8 +1093,8 @@ fun ChapterWebView(
activeHighlightPalette.forEachIndexed { index, colorEnum ->
Box(
modifier = Modifier
.padding(horizontal = 6.dp)
.size(32.dp)
.padding(horizontal = 4.dp)
.size(28.dp)
.background(colorEnum.color, CircleShape)
.pointerInput(colorEnum) {
detectTapGestures(onTap = {
@ -1115,7 +1121,7 @@ fun ChapterWebView(
Spacer(modifier = Modifier.width(8.dp))
SpectrumButton(
onClick = { showPaletteManager = true }, size = 32.dp
onClick = { showPaletteManager = true }, size = 28.dp
)
}
@ -1123,7 +1129,7 @@ fun ChapterWebView(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
.padding(horizontal = 6.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {

View file

@ -66,7 +66,7 @@ suspend fun summarizeBookContent(
onFinish: () -> Unit
) {
if (content.isBlank()) {
onError("The book content is empty.")
onError(context.getString(R.string.ai_error_book_content_empty))
onFinish()
return
}
@ -75,7 +75,7 @@ suspend fun summarizeBookContent(
@Suppress("KotlinConstantConditions")
if (BuildConfig.FLAVOR == "oss") {
if (BuildConfig.IS_OFFLINE) {
onError("AI features are unavailable in the offline OSS build.")
onError(context.getString(R.string.ai_error_offline_oss))
onFinish()
return
}
@ -154,7 +154,7 @@ suspend fun summarizeBookContent(
}
}
if (!hasReceivedData) {
onError("Failed to parse summary from server response.")
onError(context.getString(R.string.ai_error_parse_summary))
}
} else {
val errorBody = try {
@ -162,12 +162,12 @@ suspend fun summarizeBookContent(
} catch (_: Exception) { null }
val errorDetail = try {
JSONObject(errorBody.toString()).getString("detail")
} catch (_: Exception) { "Could not fetch summary." }
onError("Error: $responseCode. $errorDetail")
} catch (_: Exception) { context.getString(R.string.ai_error_fetch_summary) }
onError(context.getString(R.string.ai_error_with_code, responseCode, errorDetail))
}
} catch (e: Exception) {
Timber.e(e, "Network error during summarization: ${e.message}")
onError("Network error. Please check connection and server status.")
onError(context.getString(R.string.ai_error_network_server))
} finally {
connection?.disconnect()
onFinish()

View file

@ -495,7 +495,7 @@ fun PaginatedTextSelectionMenu(
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) {
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 180.dp)) {
if (onHighlight != null) {
HighlightColorRow(
activeHighlightPalette = activeHighlightPalette,
@ -531,7 +531,7 @@ fun PaginatedTextSelectionMenu(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = 6.dp, vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
@ -539,23 +539,23 @@ fun PaginatedTextSelectionMenu(
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
Column(
modifier = Modifier
.width(64.dp)
.width(56.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { action.onClick() }
.padding(vertical = 8.dp),
.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (action.imageVector != null) {
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
} else if (action.iconRes != null) {
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(2.dp))
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
}
}
repeat(3 - rowActions.size) {
Spacer(modifier = Modifier.width(64.dp))
Spacer(modifier = Modifier.width(56.dp))
}
}
}
@ -582,7 +582,7 @@ fun HighlightColorRow(
) {
Row(
modifier = modifier
.padding(vertical = 12.dp, horizontal = 12.dp)
.padding(vertical = 8.dp, horizontal = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
@ -591,8 +591,8 @@ fun HighlightColorRow(
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(horizontal = 6.dp)
.size(32.dp)
.padding(horizontal = 4.dp)
.size(28.dp)
.clip(CircleShape) // 1. Clip shape for ripple
.background(colorEnum.color) // 2. Apply background
.clickable {
@ -610,17 +610,17 @@ fun HighlightColorRow(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected),
tint = if (colorEnum == HighlightColor.WHITE || colorEnum == HighlightColor.YELLOW) Color.Black else Color.White,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(16.dp)
)
}
}
}
if (onOpenPaletteManager != null) {
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(6.dp))
SpectrumButton(
onClick = onOpenPaletteManager,
size = 32.dp
size = 28.dp
)
}
}
@ -650,7 +650,7 @@ fun PaginatedTextSelectionMenu(
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 200.dp)) {
Column(modifier = Modifier.width(IntrinsicSize.Max).widthIn(min = 180.dp)) {
// 1. Colors Row
if (onHighlight != null) {
HighlightColorRow(
@ -731,7 +731,7 @@ fun PaginatedTextSelectionMenu(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = 6.dp, vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
@ -739,22 +739,22 @@ fun PaginatedTextSelectionMenu(
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
Column(
modifier = Modifier
.width(64.dp)
.width(56.dp)
.clickable { action.onClick() }
.padding(vertical = 8.dp),
.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (action.imageVector != null) {
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
} else if (action.iconRes != null) {
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(22.dp))
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(2.dp))
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
}
}
repeat(3 - rowActions.size) {
Spacer(modifier = Modifier.width(64.dp))
Spacer(modifier = Modifier.width(56.dp))
}
}
}

View file

@ -26,6 +26,7 @@ import android.graphics.Canvas
import android.os.Build
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
@ -153,26 +154,26 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.roundToInt
enum class ReaderTool(val title: String, val category: String) {
DICTIONARY("External Apps", "Top Bar"),
THEME("Theme Settings", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
FORMAT("Text Formatting", "Bottom Bar"),
SEARCH("Search", "Bottom Bar"),
AI_FEATURES("AI Features", "Bottom Bar"),
TTS_CONTROLS("TTS Controls", "Bottom Bar"),
READING_MODE("Reading Mode", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"),
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
VOLUME_SCROLL("Volume Button Scrolling", "Overflow Menu"),
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
THEME(R.string.tooltip_theme_desc, "Top Bar"),
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
TOC(R.string.tool_sidebar, "Bottom Bar"),
FORMAT(R.string.content_desc_text_formatting, "Bottom Bar"),
SEARCH(R.string.action_search, "Bottom Bar"),
AI_FEATURES(R.string.ai_features_title, "Bottom Bar"),
TTS_CONTROLS(R.string.tool_tts_controls, "Bottom Bar"),
READING_MODE(R.string.tool_reading_mode, "Overflow Menu"),
BOOKMARK(R.string.content_desc_bookmark, "Overflow Menu"),
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
VOLUME_SCROLL(R.string.menu_volume_button_scrolling, "Overflow Menu"),
PAGE_TURN_ANIM(R.string.menu_realistic_page_turns, "Overflow Menu"),
KEEP_SCREEN_ON(R.string.menu_keep_screen_on, "Overflow Menu"),
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu")
}
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
@ -182,7 +183,8 @@ data class FlatToolItem(
val type: FlatItemType,
val tool: ReaderTool? = null,
val section: ToolbarSection? = null,
val title: String? = null
val title: String? = null,
@StringRes val titleRes: Int? = null
)
fun sanitizePlaceholders(list: List<FlatToolItem>): List<FlatToolItem> {
@ -197,7 +199,7 @@ fun sanitizePlaceholders(list: List<FlatToolItem>): List<FlatToolItem> {
}
ToolbarSection.entries.forEach { section ->
result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
val tools = sectionMap[section] ?: emptyList()
if (tools.isEmpty()) {
@ -266,6 +268,51 @@ private val epubToolbarTools = setOf(
ReaderTool.SCREEN_ORIENTATION
)
internal fun defaultReaderHiddenTools(): Set<String> = setOf(ReaderTool.SCREEN_ORIENTATION.name)
internal fun defaultReaderToolOrder(): List<ReaderTool> = ReaderTool.entries.toList()
internal fun defaultReaderBottomTools(): Set<String> {
return ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
}
internal fun buildReaderToolbarItems(
hiddenTools: Set<String>,
toolOrder: List<ReaderTool>,
bottomTools: Set<String>
): List<FlatToolItem> {
val toolbarTools = toolOrder.filter { it in epubToolbarTools }
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
val moreTools = toolOrder.filter { it !in epubToolbarTools }
val list = mutableListOf<FlatToolItem>()
ToolbarSection.entries.forEach { section ->
val tools = when (section) {
ToolbarSection.TOP -> topTools
ToolbarSection.BOTTOM -> bottomToolsList
ToolbarSection.HIDDEN -> hiddenToolsList
}
list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, titleRes = section.titleRes))
if (tools.isEmpty()) {
list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
} else {
tools.forEach { tool ->
list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
}
}
}
list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, titleRes = R.string.toolbar_more_menu))
moreTools.forEach { tool ->
list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
}
return list
}
@Composable
fun EpubReaderTopBar(
isVisible: Boolean,
@ -478,7 +525,7 @@ fun EpubReaderTopBar(
if (hiddenToolbarTools.isNotEmpty()) {
DropdownMenuItem(
text = { Text("Hidden tools") },
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
trailingIcon = {
Icon(
@ -1717,37 +1764,11 @@ fun CustomizeToolsSheet(
var flatItems by remember {
mutableStateOf(
run {
val toolbarTools = toolOrder.filter { it in epubToolbarTools }
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
val moreTools = toolOrder.filter { it !in epubToolbarTools }
val list = mutableListOf<FlatToolItem>()
ToolbarSection.entries.forEach { section ->
val tools = when(section) {
ToolbarSection.TOP -> topTools
ToolbarSection.BOTTOM -> bottomToolsList
ToolbarSection.HIDDEN -> hiddenToolsList
}
list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
if (tools.isEmpty()) {
list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
} else {
tools.forEach { tool ->
list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
}
}
}
list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More menu"))
moreTools.forEach { tool ->
list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
}
list
}
buildReaderToolbarItems(
hiddenTools = hiddenTools,
toolOrder = toolOrder,
bottomTools = bottomTools
)
)
}
@ -1812,6 +1833,22 @@ fun CustomizeToolsSheet(
}
}
val resetToDefault = {
val defaultHiddenTools = defaultReaderHiddenTools()
val defaultToolOrder = defaultReaderToolOrder()
val defaultBottomTools = defaultReaderBottomTools()
localHiddenTools = defaultHiddenTools
flatItems = buildReaderToolbarItems(
hiddenTools = defaultHiddenTools,
toolOrder = defaultToolOrder,
bottomTools = defaultBottomTools
)
onUpdate(defaultHiddenTools)
onPlacementUpdate(defaultBottomTools)
onOrderUpdate(defaultToolOrder)
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
@ -1828,12 +1865,17 @@ fun CustomizeToolsSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Customize Toolbar",
text = stringResource(R.string.title_customize_toolbar),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
TextButton(onClick = resetToDefault) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.action_reset))
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
@ -1866,8 +1908,9 @@ fun CustomizeToolsSheet(
) {
when (item.type) {
FlatItemType.SECTION_HEADER -> {
val titleRes = item.titleRes
Text(
text = item.title ?: "",
text = if (titleRes != null) stringResource(titleRes) else item.title.orEmpty(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
@ -1883,7 +1926,7 @@ fun CustomizeToolsSheet(
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.toolbar_drop_tools_here), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
FlatItemType.TOOL -> {
@ -1900,8 +1943,9 @@ fun CustomizeToolsSheet(
)
}
FlatItemType.MORE_HEADER -> {
val titleRes = item.titleRes
Text(
text = item.title ?: "More menu",
text = if (titleRes != null) stringResource(titleRes) else item.title ?: stringResource(R.string.toolbar_more_menu),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
@ -1909,7 +1953,7 @@ fun CustomizeToolsSheet(
}
FlatItemType.MORE_TOOL -> {
MoreToolVisibilityRow(
title = item.tool!!.title,
title = stringResource(item.tool!!.titleRes),
visible = !localHiddenTools.contains(item.tool.name),
onToggle = {
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
@ -1952,14 +1996,14 @@ private fun ToolbarDragRow(
ToolPreviewIcon(tool)
Spacer(Modifier.width(16.dp))
Text(
text = tool.title,
text = stringResource(tool.titleRes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(
Icons.Default.Menu,
contentDescription = "Drag to reorder",
contentDescription = stringResource(R.string.content_desc_drag_to_reorder),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(32.dp)
@ -2007,25 +2051,26 @@ private fun MoreToolVisibilityRow(
}
}
enum class ToolbarSection(val title: String) {
TOP("Top Bar"),
BOTTOM("Bottom Bar"),
HIDDEN("Hidden Tools")
enum class ToolbarSection(@StringRes val titleRes: Int) {
TOP(R.string.toolbar_top_bar),
BOTTOM(R.string.toolbar_bottom_bar),
HIDDEN(R.string.toolbar_hidden_tools)
}
@Composable
private fun ToolPreviewIcon(tool: ReaderTool) {
val title = stringResource(tool.titleRes)
when (tool) {
ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
}
}
@ -2050,7 +2095,7 @@ private fun HiddenEpubToolMenuItem(
else -> true
}
DropdownMenuItem(
text = { Text(tool.title) },
text = { Text(stringResource(tool.titleRes)) },
enabled = enabled,
onClick = {
showMoreMenu()
@ -2198,7 +2243,11 @@ fun TtsOverlayControls(
shape = RoundedCornerShape(8.dp)
) {
Text(
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) "✨ Cloud" else "📱 Device",
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
stringResource(R.string.tts_mode_cloud_ai)
} else {
stringResource(R.string.tts_mode_device_native)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
@ -2211,7 +2260,7 @@ fun TtsOverlayControls(
) {
val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default"
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: stringResource(R.string.label_default)
Text(
voiceName,

View file

@ -253,6 +253,15 @@ private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
private const val TAG_LINK_NAV = "LINK_NAV"
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String {
return text
.replace('\n', ' ')
.replace('\r', ' ')
.replace('\t', ' ')
.take(maxLength)
}
private fun View.bottomRoundedCornerRadiusPx(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
@ -303,7 +312,7 @@ private fun loadHiddenTools(context: Context): Set<String> {
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
val migratedHiddenTools = savedHiddenTools + ReaderTool.SCREEN_ORIENTATION.name
val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools()
prefs.edit {
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
@ -325,7 +334,7 @@ private fun loadToolOrder(context: Context): List<ReaderTool> {
?.filter { it.isNotBlank() }
?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
.orEmpty()
return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct()
return (savedTools + defaultReaderToolOrder().filterNot { it in savedTools }).distinct()
}
private fun saveBottomTools(context: Context, bottomTools: Set<String>) {
@ -337,8 +346,8 @@ private fun loadBottomTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getStringSet(
BOTTOM_TOOLS_KEY,
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
defaultReaderBottomTools()
) ?: defaultReaderBottomTools()
}
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
@ -2413,13 +2422,13 @@ fun EpubReaderHost(
val targetPageIndex = pageIndex
val targetCfi = cfi.orEmpty()
if (targetPageIndex != null && (targetCfi.isBlank() || targetCfi.startsWith("android-page:"))) {
return "Page ${targetPageIndex + 1}"
return context.getString(R.string.pdf_page_short, targetPageIndex + 1)
}
val chapter = chapterIndex
return if (chapter != null) {
chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: "Chapter ${chapter + 1}"
chapters.getOrNull(chapter)?.title?.takeIf { it.isNotBlank() } ?: context.getString(R.string.chapter_number_format, chapter + 1)
} else {
"Location"
context.getString(R.string.location_generic)
}
}
@ -3262,7 +3271,7 @@ fun EpubReaderHost(
} ?: run {
isSummarizationLoading = false
summarizationResult =
SummarizationResult(error = "WebView not available.")
SummarizationResult(error = context.getString(R.string.error_webview_not_available))
}
}
}
@ -3333,7 +3342,7 @@ fun EpubReaderHost(
if (fullSummary.isNotBlank()) {
val chapterTitle =
chapters.getOrNull(chapterIndex)?.title
?: "Chapter ${chapterIndex + 1}"
?: context.getString(R.string.chapter_number_format, chapterIndex + 1)
summaryCacheManager.saveSummary(
epubBook.title,
chapterIndex,
@ -3344,12 +3353,12 @@ fun EpubReaderHost(
})
} else {
summarizationResult =
SummarizationResult(error = "Could not get chapter content.")
SummarizationResult(error = context.getString(R.string.error_could_not_get_chapter_content))
isSummarizationLoading = false
}
} else {
summarizationResult =
SummarizationResult(error = "Could not determine current chapter.")
SummarizationResult(error = context.getString(R.string.error_could_not_determine_chapter))
isSummarizationLoading = false
}
}
@ -4207,7 +4216,7 @@ fun EpubReaderHost(
isSummarizationLoading = false
val fullSummary = finalSummaryBuilder.toString()
if (fullSummary.isNotBlank()) {
val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: "Chapter ${chapterIndexToSave + 1}"
val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: context.getString(R.string.chapter_number_format, chapterIndexToSave + 1)
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary)
}
}
@ -4362,7 +4371,7 @@ fun EpubReaderHost(
)
val chapterTitle =
epubBook.chapters.getOrNull(currentChapterIndex)?.title
?: "Unknown Chapter"
?: context.getString(R.string.unknown_chapter)
val newBookmark = Bookmark(
cfi = cfi,
chapterTitle = chapterTitle,
@ -4571,15 +4580,33 @@ fun EpubReaderHost(
highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1)
},
onHighlightCreated = { cfi, text, colorId ->
val chapterIndex = currentChapterInPaginatedMode ?: 0
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
"persist_request cfi=$cfi colorId=$colorId chapter=$chapterIndex " +
"existingCount=${userHighlights.size} textLen=${text.length} " +
"text='${epubHighlightDiagSnippet(text)}'"
)
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val finalCfi = processAndAddHighlight(
newCfi = cfi,
newText = text,
newColor = color,
chapterIndex = currentChapterInPaginatedMode ?: 0,
chapterIndex = chapterIndex,
currentList = userHighlights
)
val savedHighlight = userHighlights.find {
it.chapterIndex == chapterIndex && it.cfi == finalCfi
}
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
"persist_result finalCfi=$finalCfi chapter=$chapterIndex " +
"savedId=${savedHighlight?.id} totalCount=${userHighlights.size} " +
"matchingCfiCount=${userHighlights.count { it.chapterIndex == chapterIndex && it.cfi == finalCfi }} " +
"locatorStart=${savedHighlight?.locator?.startOffset} " +
"locatorEnd=${savedHighlight?.locator?.endOffset} " +
"locatorPage=${savedHighlight?.locator?.pageIndex} " +
"locatorCfi=${savedHighlight?.locator?.cfi}"
)
if (pendingNoteForNewHighlight) {
pendingNoteForNewHighlight = false
highlightToNoteCfi = finalCfi
@ -4616,9 +4643,24 @@ fun EpubReaderHost(
)?.let { recordEpubJump(it) }
},
onHighlightDeleted = { cfi ->
val beforeCount = userHighlights.size
val toRemove = userHighlights.find { it.cfi == cfi }
if (toRemove != null) {
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
"delete_request cfi=$cfi matchedId=${toRemove.id} " +
"matchedChapter=${toRemove.chapterIndex} beforeCount=$beforeCount " +
"locatorStart=${toRemove.locator.startOffset} " +
"locatorEnd=${toRemove.locator.endOffset}"
)
userHighlights.remove(toRemove)
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).d(
"delete_result cfi=$cfi removedId=${toRemove.id} " +
"afterCount=${userHighlights.size}"
)
} else {
Timber.tag(TAG_PAGINATED_HIGHLIGHT_DIAG).w(
"delete_request cfi=$cfi matchedId=null beforeCount=$beforeCount"
)
}
}
)
@ -4745,7 +4787,7 @@ fun EpubReaderHost(
val finalCfi = if (offset > 0) "$baseCfi:$offset" else baseCfi
val chapterIndex = paginator?.findChapterIndexForPage(paginatedPagerState.currentPage)
val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: "Unknown Chapter"
val chapterTitle = chapterIndex?.let { epubBook.chapters.getOrNull(it)?.title } ?: context.getString(R.string.unknown_chapter)
val snippet = (targetBlockForBookmark as? TextContentBlock)?.content?.text?.take(150) ?: ""
val pageInChapter: Int?
@ -4862,7 +4904,7 @@ fun EpubReaderHost(
val textToShow = if (bookPaginator != null && chapterIndex != null) {
val chapterTitle =
chapters.getOrNull(chapterIndex)?.title?.take(30)?.trim()
?: "Chapter"
?: stringResource(R.string.chapter)
val totalPagesInChapter = bookPaginator.chapterPageCounts[chapterIndex]
val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex]
@ -4874,7 +4916,7 @@ fun EpubReaderHost(
chapterTitle
}
} else {
"Page ${paginatedPagerState.currentPage + 1}/${paginatedPagerState.pageCount}"
stringResource(R.string.page_number_of_total, paginatedPagerState.currentPage + 1, paginatedPagerState.pageCount)
}
Text(
@ -5568,7 +5610,7 @@ fun EpubReaderHost(
onVerticalMarginChange = { currentVerticalMargin = it },
currentFont = currentFontFamily,
currentCustomFontName = if(currentCustomFontPath != null) {
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: stringResource(R.string.custom_font_fallback)
} else null,
onFontOptionClick = { showFontSelectionSheet = true },
currentTextAlign = currentTextAlign,
@ -5649,7 +5691,7 @@ fun EpubReaderHost(
credits = credits,
isProUser = isProUser,
currentChapterIndex = effectiveCurrentChapterIndex,
chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: "Chapter ${effectiveCurrentChapterIndex + 1}",
chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: context.getString(R.string.chapter_number_format, effectiveCurrentChapterIndex + 1),
showAiHubSheet = showAiHubSheet,
onGenerateSummary = handleGenerateSummary,
onGenerateRecap = handleGenerateRecap,

View file

@ -21,6 +21,7 @@ package com.aryan.reader.epubreader
import android.content.Context
import android.net.Uri
import androidx.annotation.StringRes
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@ -161,27 +162,28 @@ enum class ReaderFont(val id: String, val displayName: String, val fontFamilyNam
LEXEND("lexend", "Lexend", "Lexend")
}
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, val displayName: String) {
DEFAULT("default", "", R.drawable.format_align_left, "Default"),
LEFT("left", "left", R.drawable.format_align_left, "Left"),
JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify")
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, @StringRes val displayNameRes: Int) {
DEFAULT("default", "", R.drawable.format_align_left, R.string.label_default),
LEFT("left", "left", R.drawable.format_align_left, R.string.label_left),
RIGHT("right", "right", R.drawable.format_align_right, R.string.label_right),
JUSTIFY("justify", "justify", R.drawable.format_align_justify, R.string.label_justify)
}
enum class SystemUiMode(val id: Int, val title: String) {
DEFAULT(0, "Always Show"),
SYNC(1, "Sync with Menus"),
HIDDEN(2, "Always Hide")
enum class SystemUiMode(val id: Int, @StringRes val titleRes: Int) {
DEFAULT(0, R.string.label_always_show),
SYNC(1, R.string.label_sync_with_menus),
HIDDEN(2, R.string.label_always_hide)
}
enum class PageInfoMode(val id: Int, val title: String) {
DEFAULT(0, "Always Show"),
SYNC(1, "Sync with Menus"),
HIDDEN(2, "Always Hide")
enum class PageInfoMode(val id: Int, @StringRes val titleRes: Int) {
DEFAULT(0, R.string.label_always_show),
SYNC(1, R.string.label_sync_with_menus),
HIDDEN(2, R.string.label_always_hide)
}
enum class PageInfoPosition(val id: Int, val title: String) {
BOTTOM(0, "Bottom"),
TOP(1, "Top")
enum class PageInfoPosition(val id: Int, @StringRes val titleRes: Int) {
BOTTOM(0, R.string.label_bottom),
TOP(1, R.string.label_top)
}
data class FormatSettings(
@ -649,6 +651,7 @@ fun ReaderTextFormatPanel(
Row {
ReaderTextAlign.entries.forEach { align ->
val isSelected = currentTextAlign == align
val alignDisplayName = stringResource(align.displayNameRes)
Column(
modifier = Modifier
.fillMaxHeight()
@ -661,12 +664,12 @@ fun ReaderTextFormatPanel(
) {
Icon(
painter = painterResource(id = align.iconResId),
contentDescription = align.displayName,
contentDescription = alignDisplayName,
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Text(
text = align.displayName,
text = alignDisplayName,
style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp,
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
@ -923,7 +926,7 @@ fun VisualOptionsSheet(
options = SystemUiMode.entries,
selectedOption = systemUiMode,
onOptionSelected = onSystemUiModeChange,
getLabel = { it.title }
getLabel = { stringResource(it.titleRes) }
)
Spacer(modifier = Modifier.height(24.dp))
@ -936,7 +939,7 @@ fun VisualOptionsSheet(
options = PageInfoMode.entries,
selectedOption = pageInfoMode,
onOptionSelected = onPageInfoModeChange,
getLabel = { it.title }
getLabel = { stringResource(it.titleRes) }
)
Spacer(modifier = Modifier.height(16.dp))
@ -946,7 +949,7 @@ fun VisualOptionsSheet(
options = PageInfoPosition.entries,
selectedOption = pageInfoPosition,
onOptionSelected = onPageInfoPositionChange,
getLabel = { it.title }
getLabel = { stringResource(it.titleRes) }
)
Spacer(modifier = Modifier.height(24.dp))
@ -1003,7 +1006,7 @@ fun <T> OptionSegmentedControl(
options: List<T>,
selectedOption: T,
onOptionSelected: (T) -> Unit,
getLabel: (T) -> String
getLabel: @Composable (T) -> String
) {
Row(
modifier = Modifier

View file

@ -33,6 +33,7 @@ import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.PopupMenu
import android.webkit.JavascriptInterface
import org.json.JSONObject
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
@ -52,6 +53,9 @@ class InteractiveWebView(
companion object {
private const val DRAG_SENSITIVITY_PX = 20f
private const val SELECTION_MENU_INITIAL_DELAY_MS = 120L
private const val SELECTION_MENU_RETRY_DELAY_MS = 140L
private const val SELECTION_MENU_RETRY_COUNT = 8
}
private var startY: Float = 0f
@ -60,16 +64,26 @@ class InteractiveWebView(
private val scrollStopHandler = Handler(Looper.getMainLooper())
private var scrollStopRunnable: Runnable? = null
private var selectionMenuRunnable: Runnable? = null
private var activeSelectionActionMode: ActionMode? = null
private var selectionMenuShownForActiveMode = false
init {
addJavascriptInterface(ReaderSelectionBridge(), "ReaderSelectionBridge")
}
private fun clearPendingSelectionWork() {
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
scrollStopRunnable = null
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
selectionMenuRunnable = null
}
private fun startLocalSelectionActionMode(): ActionMode {
private fun startLocalSelectionActionMode(scheduleMenu: Boolean = true): ActionMode {
activeSelectionActionMode?.let { existingMode ->
showCustomSelectionMenuFromCurrentSelection(existingMode)
if (scheduleMenu) {
scheduleCustomSelectionMenuFromCurrentSelection(existingMode)
}
return existingMode
}
@ -78,10 +92,14 @@ class InteractiveWebView(
if (activeSelectionActionMode === localMode) {
activeSelectionActionMode = null
}
selectionMenuShownForActiveMode = false
onHideCustomSelectionMenu()
}
selectionMenuShownForActiveMode = false
activeSelectionActionMode = localMode
showCustomSelectionMenuFromCurrentSelection(localMode)
if (scheduleMenu) {
scheduleCustomSelectionMenuFromCurrentSelection(localMode)
}
return localMode
}
@ -90,7 +108,56 @@ class InteractiveWebView(
activeSelectionActionMode = null
}
private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) {
private fun scheduleCustomSelectionMenuFromCurrentSelection(
mode: ActionMode,
delayMs: Long = SELECTION_MENU_INITIAL_DELAY_MS,
remainingRetries: Int = SELECTION_MENU_RETRY_COUNT
) {
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
selectionMenuRunnable = Runnable {
selectionMenuRunnable = null
showCustomSelectionMenuFromCurrentSelection(mode, remainingRetries)
}
scrollStopHandler.postDelayed(selectionMenuRunnable!!, delayMs)
}
private fun scheduleSelectionActionModeRefreshAfterTouch(
delayMs: Long = SELECTION_MENU_INITIAL_DELAY_MS,
remainingRetries: Int = SELECTION_MENU_RETRY_COUNT
) {
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
selectionMenuRunnable = Runnable {
selectionMenuRunnable = null
activeSelectionActionMode?.let { mode ->
showCustomSelectionMenuFromCurrentSelection(mode, SELECTION_MENU_RETRY_COUNT)
return@Runnable
}
evaluateJavascript("(function() { var s = window.getSelection && window.getSelection(); return s ? s.toString().trim() : ''; })();") { result ->
val selectedText = result?.removeSurrounding("\"")
if (!selectedText.isNullOrBlank() && activeSelectionActionMode == null) {
Timber.d("CustomSelection: selection exists after touch-up. Starting local action mode.")
startLocalSelectionActionMode()
} else {
activeSelectionActionMode?.let { mode ->
showCustomSelectionMenuFromCurrentSelection(mode, SELECTION_MENU_RETRY_COUNT)
return@evaluateJavascript
}
if (remainingRetries > 0) {
scheduleSelectionActionModeRefreshAfterTouch(
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
remainingRetries = remainingRetries - 1
)
}
}
}
}
scrollStopHandler.postDelayed(selectionMenuRunnable!!, delayMs)
}
private fun showCustomSelectionMenuFromCurrentSelection(
mode: ActionMode,
remainingRetries: Int
) {
val jsToGetSelectionDetails = """
(function() {
var selection = window.getSelection();
@ -99,20 +166,44 @@ class InteractiveWebView(
return null;
}
var range = selection.getRangeAt(0);
var rect = range.getBoundingClientRect();
// If getBoundingClientRect returns all zeros, try getClientRects()
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
var clientRects = range.getClientRects();
if (clientRects.length > 0) {
rect = clientRects[0]; // Use the first rect
} else {
return null; // No valid rect found
var viewportLeft = 0;
var viewportTop = 0;
var viewportRight = window.innerWidth || document.documentElement.clientWidth || 0;
var viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0;
var rects = Array.prototype.slice.call(range.getClientRects ? range.getClientRects() : []);
rects = rects.filter(function(rect) {
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
return rect.right >= viewportLeft &&
rect.left <= viewportRight &&
rect.bottom >= viewportTop &&
rect.top <= viewportBottom;
});
var rect = null;
if (rects.length > 0) {
var firstRect = rects[0];
rect = rects.reduce(function(acc, item) {
return {
left: Math.min(acc.left, item.left),
top: Math.min(acc.top, item.top),
right: Math.max(acc.right, item.right),
bottom: Math.max(acc.bottom, item.bottom)
};
}, {
left: firstRect.left,
top: firstRect.top,
right: firstRect.right,
bottom: firstRect.bottom
});
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;
} else {
rect = range.getBoundingClientRect ? range.getBoundingClientRect() : null;
if (!rect || rect.width <= 0 || rect.height <= 0) {
return null;
}
}
// Ensure the rect has some dimension
if (rect.width === 0 && rect.height === 0) {
if (rect.width <= 0 || rect.height <= 0) {
return null;
}
@ -129,13 +220,28 @@ class InteractiveWebView(
""".trimIndent()
evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
fun retryOrFinish(message: String) {
Timber.d(message)
if (selectionMenuShownForActiveMode) {
return
}
if (activeSelectionActionMode === mode && remainingRetries > 0) {
scheduleCustomSelectionMenuFromCurrentSelection(
mode = mode,
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
remainingRetries = remainingRetries - 1
)
} else {
mode.finish()
}
}
if (activeSelectionActionMode !== mode) {
return@evaluateJavascript
}
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
mode.finish()
retryOrFinish("CustomSelection: JS returned null or invalid for selection details. Retries left: $remainingRetries")
return@evaluateJavascript
}
@ -148,8 +254,7 @@ class InteractiveWebView(
val selectedText = selectionDetails.getString("text")
if (selectedText.isBlank()) {
Timber.d("CustomSelection: Selected text is blank after JS processing.")
mode.finish()
retryOrFinish("CustomSelection: Selected text is blank after JS processing. Retries left: $remainingRetries")
return@evaluateJavascript
}
@ -161,8 +266,7 @@ class InteractiveWebView(
val jsHeight = selectionDetails.getDouble("height")
if (jsWidth == 0.0 && jsHeight == 0.0) {
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
mode.finish()
retryOrFinish("CustomSelection: JS returned a zero-area rect. Retries left: $remainingRetries. Left: $jsLeft, Top: $jsTop")
return@evaluateJavascript
}
@ -181,20 +285,85 @@ class InteractiveWebView(
)
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
mode.finish()
retryOrFinish("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. Retries left: $remainingRetries. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
return@evaluateJavascript
}
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
selectionMenuShownForActiveMode = true
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
mode.finish()
}
} catch (e: Exception) {
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
if (selectionMenuShownForActiveMode) {
return@evaluateJavascript
}
if (remainingRetries > 0) {
scheduleCustomSelectionMenuFromCurrentSelection(
mode = mode,
delayMs = SELECTION_MENU_RETRY_DELAY_MS,
remainingRetries = remainingRetries - 1
)
} else {
mode.finish()
}
}
}
}
private fun showCustomSelectionMenuFromSelectionDetailsJson(
mode: ActionMode,
rawJson: String
) {
if (activeSelectionActionMode !== mode) return
selectionMenuRunnable?.let { scrollStopHandler.removeCallbacks(it) }
selectionMenuRunnable = null
try {
val selectionDetails = JSONObject(rawJson)
val selectedText = selectionDetails.getString("text")
if (selectedText.isBlank()) {
return
}
val jsLeft = selectionDetails.getDouble("left")
val jsTop = selectionDetails.getDouble("top")
val jsRight = selectionDetails.getDouble("right")
val jsBottom = selectionDetails.getDouble("bottom")
val jsWidth = selectionDetails.getDouble("width")
val jsHeight = selectionDetails.getDouble("height")
if (jsWidth <= 0.0 || jsHeight <= 0.0) {
return
}
val density = context.resources.displayMetrics.density
val webViewLocation = IntArray(2)
getLocationOnScreen(webViewLocation)
val webViewX = webViewLocation[0]
val webViewY = webViewLocation[1]
val selectionRectScreen = Rect(
(webViewX + jsLeft * density).toInt(),
(webViewY + jsTop * density).toInt(),
(webViewX + jsRight * density).toInt(),
(webViewY + jsBottom * density).toInt()
)
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
Timber.d("CustomSelection: Bridge supplied invalid rect: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom")
return
}
Timber.d("CustomSelection: Bridge selected text '$selectedText', Screen Rect: $selectionRectScreen")
selectionMenuShownForActiveMode = true
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
mode.finish()
}
} catch (e: Exception) {
Timber.e(e, "CustomSelection: Error parsing bridge selection details: '$rawJson'")
}
}
@ -292,6 +461,8 @@ class InteractiveWebView(
if (wasDragging) {
Timber.d("Drag operation ended, enabling text selection.")
evaluateJavascript("javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(true);", null)
} else if (event.actionMasked == MotionEvent.ACTION_UP) {
scheduleSelectionActionModeRefreshAfterTouch()
}
}
}
@ -306,12 +477,14 @@ class InteractiveWebView(
// MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView
// selections use the app's Compose popup without starting the platform toolbar.
override fun startActionMode(originalCallback: ActionMode.Callback): ActionMode? {
Timber.d("CustomSelection: handling primary action mode locally.")
return startLocalSelectionActionMode()
}
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
if (type == ActionMode.TYPE_FLOATING) {
Timber.d("CustomSelection: handling floating action mode locally.")
return startLocalSelectionActionMode()
}
return super.startActionMode(originalCallback, type)
Timber.d("CustomSelection: handling action mode locally. Type: $type")
return startLocalSelectionActionMode()
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
@ -392,4 +565,14 @@ class InteractiveWebView(
override fun getMenuInflater(): MenuInflater = menuInflater
}
private inner class ReaderSelectionBridge {
@JavascriptInterface
fun onSelectionChanged(selectionJson: String) {
post {
val mode = startLocalSelectionActionMode(scheduleMenu = false)
showCustomSelectionMenuFromSelectionDetailsJson(mode, selectionJson)
}
}
}
}