Windows ga (#358)

* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app

* Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app

* Persist reader window state and improve slider interaction in desktop app

* Improve PDF page transitions and refine focus management in desktop app

* Refactor scrollbar interaction and adjust desktop modal focus handling

* Improve PDF sidecar synchronization and cross-platform metadata compatibility

* Refactor PDF annotation comment logic to shared module and implement Desktop UI

* Refactor reader screen to use tap-to-toggle and full-width styling in desktop app

* Refactor reader workspace layout and chrome-panel interactions

* Implement global search keyboard shortcuts and focusable chrome layers

* Implement flavor-specific legal links and update the About UI

* Refactor reader UI controls on desktop app

* Enhance desktop folder sync with background metadata extraction and improved error handling

* Refactor Library UI and remove redundant Home tab in desktop app

* Add custom tooltips to reader icon buttons in desktop app

* Integrate app theme controls into reader interfaces on desktop

* Add right-to-left pagination support and improve focus restoration on desktop app

* Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app

* Update desktop reader defaults and implement settings migration

* Implement block-based position tracking in ReaderLocator

* Enhance EPUB highlighting reliability in desktop app

* Add support for custom reader themes and update highlight palette logic in desktop app

* Replace the Tools panel with a "More" dropdown menu and refactor account UI

* Implement account profile header in desktop sidebar

* Implement cloud sync reliability improvements and sidebar toggle on desktop app

* Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app

* Integrate WebView2 for EPUB vertical rendering on Windows

* Refactor reader layout logic and enhance WebView2 diagnostics

* Improve vertical reading layout and WebView2 resizing on Desktop

* Refine vertical reading mode layout and margin handling

* Enhance reader locator precision and Desktop mode-switching reliability

* Implement chapter-level caching and warm-start pagination in desktop app

* Replace bundled KCEF with native system webviews via SWT

* Refactor EPUB page info bar visibility and layout logic

* Improve PDF toolbar persistence and fix tab reactivation logic

* Enable multi-selection and bulk operations for custom fonts

* Refactor instrumentation tests

* Add EPUB UI test fixture and initial instrumentation tests

* Expand EpubReader UI tests and improve accessibility

* Add instrumentation tests and test tags for library and reader screens

* Enhance OPDS parser logic and catalog integration

* Add support for toggling local synchronization on a per-folder basis.

* Implement tri-state sizing for the TTS overlay

* Persist TTS overlay size across sessions

* Refactor reader brightness control and add incremental step buttons

* Improve CSS support, pagination control, and style-aware semantic caching

* Improve link handling, interaction, and diagnostics in the paginated reader

* crash fixes

* Implement persistent pending removal for external files

* Implement book-specific word replacements

* Add native vertical reading mode with custom renderer

* Implement text selection and navigation improvements for the native vertical reader

* Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub

* Implement lazy loading and chapter prefetching for native vertical reader

* Improve window lifecycle and disposal handling on Desktop

* Optimize vertical reading performance in desktop app

* Enhance TTS start accuracy and diagnostic logging on desktop

* Refactor AI settings visibility on desktop

* Improve pagination height measurement and enhance cutoff diagnostics

* Implement lifecycle management and improve justified text splitting for pagination

* Refine AI usage tracking and force AI feature visibility on Desktop

* Add descriptive context comments and usage examples to string and plural resources.

* Optimize performance and memory usage in search and state mapping

* Replace reader page sliders with minimal slider and navigation controls

* Add support for CBT comic archives

* Harden file path validation and XML parsing to prevent security vulnerabilities

* Implement local account profile caching and optimize desktop performance

* Improve desktop persistence reliability and add Linux secure storage support

* Improved PDF zoom stability and layout prediction during zoom commits

* Improved PDF spread layout prediction, reader focus restoration, and account profile caching

* Enhance highlight precision and scoping using block-local offsets and CFIs

* Enhance cloud book content synchronization and background downloads

* Implement granular timestamp tracking for reading positions and PDF annotations

* Restrict diagnostic logging and stack traces to debug builds

* Refine PDF page gaps and reader chrome interaction logic

* Refactor PDF highlight rendering and overhaul Desktop sidebar UI

* Implement a new interaction dock and undo/redo history for PDF annotations in desktop

* Enhance PDF color picker and improve navigation scroll restoration

* Add highlight palette customization and improve selection menu UI in desktop app epub reader

* Enhance desktop shelf management and library organization
This commit is contained in:
Aryan 2026-06-02 00:51:42 +05:30 committed by GitHub
parent 5971eaa571
commit 83dcafa4b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
444 changed files with 47279 additions and 8096 deletions

View file

@ -15,6 +15,8 @@ fun androidSettingsHubInput(
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
val featurePolicy = if (isOfflineBuild) {
SharedFeaturePolicy.OssOffline
} else if (isOssBuild) {
SharedFeaturePolicy.OssOnline
} else {
SharedFeaturePolicy.Standard
}

View file

@ -23,7 +23,8 @@ internal object AndroidSharedStateBridge {
val sharedInput = SharedLibraryProjectionInput(
state = projectionState.toSharedReaderScreenState(
rawBooks = taggedBooks,
dbTags = input.dbTags
dbTags = input.dbTags,
includeReaderAnnotations = false
),
booksFromStore = taggedBooks
.filterNot { it.bookId.endsWith("_reflow") }
@ -195,7 +196,8 @@ internal object AndroidSharedStateBridge {
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
return toSharedReaderScreenState(
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
dbTags = projectedState.allTags.ifEmpty { allTags }
dbTags = projectedState.allTags.ifEmpty { allTags },
includeReaderAnnotations = false
)
}

View file

@ -21,6 +21,7 @@ package com.aryan.reader
import android.os.Build
import timber.log.Timber
import androidx.activity.compose.BackHandler
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@ -80,6 +81,18 @@ object AppDestinations {
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
}
fun shouldInterceptAppNavBack(
currentRoute: String?,
hasPreviousBackStackEntry: Boolean,
isCurrentEntryResumed: Boolean
): Boolean {
if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false
return currentRoute != null &&
currentRoute != AppDestinations.MAIN_ROUTE &&
currentRoute != AppDestinations.PDF_VIEWER_ROUTE &&
currentRoute != AppDestinations.EPUB_READER_ROUTE
}
private fun NavHostController.isReadyForBackStackChange(): Boolean {
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
}
@ -166,6 +179,11 @@ fun AppNavigation(
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
).dp
val shouldInterceptBack = shouldInterceptAppNavBack(
currentRoute = currentRoute,
hasPreviousBackStackEntry = navController.previousBackStackEntry != null,
isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
)
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
@ -195,6 +213,10 @@ fun AppNavigation(
}
Box(modifier = Modifier.fillMaxSize()) {
BackHandler(enabled = shouldInterceptBack) {
navController.popBackStackIfReady()
}
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
@ -315,7 +337,7 @@ fun AppNavigation(
},
onRenderModeChange = viewModel::setRenderMode,
customFonts = customFonts,
onImportFont = viewModel::importFont,
onImportFonts = viewModel::importFonts,
viewModel = viewModel
)

View file

@ -0,0 +1,80 @@
package com.aryan.reader
import android.content.Context
import androidx.core.content.edit
import com.aryan.reader.shared.ReaderBookReplacementPreferences
import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson
import com.aryan.reader.shared.ReaderWordReplacementEngine
import com.aryan.reader.shared.ReaderWordReplacementRule
import org.jsoup.nodes.Document
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
private const val READER_PREFS_NAME = "reader_prefs"
private const val BOOK_REPLACEMENTS_KEY = "book_word_replacements_json"
fun loadBookReplacementPreferences(context: Context): ReaderBookReplacementPreferences {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
return ReaderBookReplacementPreferencesJson.decodeOrEmpty(prefs.getString(BOOK_REPLACEMENTS_KEY, null))
}
fun saveBookReplacementPreferences(
context: Context,
preferences: ReaderBookReplacementPreferences,
) {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putString(BOOK_REPLACEMENTS_KEY, ReaderBookReplacementPreferencesJson.encode(preferences))
}
}
internal fun applyBookReplacementsToHtmlDocument(
document: Document,
preferences: ReaderBookReplacementPreferences,
fileId: String?,
): Boolean {
val rules = preferences.activeRulesForFile(fileId)
if (rules.isEmpty()) return false
var changed = false
fun rewriteTextNodes(node: Node) {
if (node is TextNode && !node.hasReplacementBlockedAncestor()) {
val original = node.wholeText
val replaced = applyBookReplacementRules(original, rules)
if (replaced != original) {
node.text(replaced)
changed = true
}
return
}
node.childNodes().forEach(::rewriteTextNodes)
}
document.body()?.let(::rewriteTextNodes)
return changed
}
private fun applyBookReplacementRules(
text: String,
rules: List<ReaderWordReplacementRule>,
): String {
return ReaderWordReplacementEngine.apply(
text = text,
rules = rules,
).text
}
private fun TextNode.hasReplacementBlockedAncestor(): Boolean {
var current: Node? = parent()
while (current != null) {
when (current.nodeName().lowercase()) {
"script",
"style",
"noscript" -> return true
}
current = current.parent()
}
return false
}

View file

@ -0,0 +1,400 @@
package com.aryan.reader
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.ReaderBookReplacementEngine
import com.aryan.reader.shared.ReaderBookReplacementPreferences
import com.aryan.reader.shared.ReaderWordReplacementRule
private data class BookRuleEditTarget(
val ruleId: String? = null,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookWordReplacementsSheet(
isVisible: Boolean,
bookId: String,
bookTitle: String?,
preferences: ReaderBookReplacementPreferences,
onPreferencesChange: (ReaderBookReplacementPreferences) -> Unit,
onDismiss: () -> Unit,
) {
if (!isVisible) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var editTarget by remember(bookId) { mutableStateOf<BookRuleEditTarget?>(null) }
val rules = preferences.rulesForFile(bookId)
val editingRule = editTarget?.ruleId?.let { id -> rules.firstOrNull { it.id == id } }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 720.dp)
.imePadding()
.padding(horizontal = 20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.menu_book_word_replacements),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.book_replacements_current_book),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
}
Spacer(modifier = Modifier.height(12.dp))
LazyColumn(
modifier = Modifier.heightIn(max = 560.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
TextButton(
onClick = { editTarget = BookRuleEditTarget() },
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.book_replacements_add_rule))
}
}
if (editTarget != null) {
item {
BookRuleEditorCard(
seedRule = editingRule,
onCancel = { editTarget = null },
onSave = { rule ->
val updatedRules = if (editingRule == null) {
rules + rule
} else {
rules.map { if (it.id == editingRule.id) rule else it }
}
onPreferencesChange(preferences.withFileRules(bookId, updatedRules))
editTarget = null
},
)
}
}
item {
BookReplacementRuleList(
rules = rules,
emptyTextRes = R.string.book_replacements_empty,
onToggle = { rule, enabled ->
onPreferencesChange(
preferences.withFileRules(
bookId,
rules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it },
),
)
},
onEdit = { editTarget = BookRuleEditTarget(it.id) },
onDelete = { rule ->
onPreferencesChange(preferences.withFileRules(bookId, rules.filterNot { it.id == rule.id }))
},
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun BookRuleEditorCard(
seedRule: ReaderWordReplacementRule?,
onCancel: () -> Unit,
onSave: (ReaderWordReplacementRule) -> Unit,
) {
val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newBookReplacementRuleId() }
val initial = seedRule ?: ReaderWordReplacementRule(
id = draftRuleId,
from = "",
to = "",
)
var from by remember(initial.id) { mutableStateOf(initial.from) }
var to by remember(initial.id) { mutableStateOf(initial.to) }
var enabled by remember(initial.id) { mutableStateOf(initial.enabled) }
var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
val defaultPreviewInput = stringResource(R.string.book_replacements_preview_default)
var previewInput by remember(initial.id, defaultPreviewInput) {
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput)
}
val draft = ReaderWordReplacementRule(
id = initial.id,
from = from,
to = to,
enabled = enabled,
isRegex = isRegex,
matchCase = matchCase,
wholeWord = wholeWord,
)
val validation = ReaderBookReplacementEngine.validate(draft)
val previewOutput = if (validation.isValid) {
ReaderBookReplacementEngine.apply(
text = previewInput,
preferences = ReaderBookReplacementPreferences(fileRules = mapOf("preview" to listOf(draft.copy(enabled = true)))),
fileId = "preview",
).text
} else {
previewInput
}
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(if (seedRule == null) R.string.book_replacements_new_replacement else R.string.book_replacements_edit_replacement),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
OutlinedTextField(
value = from,
onValueChange = { from = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.tts_replacements_label_replace)) },
singleLine = !isRegex,
isError = !validation.isValid,
supportingText = if (validation.message != null) {
{ Text(validation.message.orEmpty()) }
} else {
null
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Text,
),
)
OutlinedTextField(
value = to,
onValueChange = { to = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.book_replacements_label_with)) },
singleLine = !isRegex,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
selected = enabled,
onClick = { enabled = !enabled },
label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) },
leadingIcon = if (enabled) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else {
null
},
)
}
item {
FilterChip(
selected = isRegex,
onClick = { isRegex = !isRegex },
label = { Text(stringResource(R.string.tts_replacements_chip_regex)) },
)
}
item {
FilterChip(
selected = wholeWord,
onClick = { wholeWord = !wholeWord },
label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) },
)
}
item {
FilterChip(
selected = matchCase,
onClick = { matchCase = !matchCase },
label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) },
)
}
}
OutlinedTextField(
value = previewInput,
onValueChange = { previewInput = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) },
minLines = 2,
)
Text(
text = previewOutput,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { onSave(draft) },
enabled = validation.isValid,
) {
Text(stringResource(R.string.action_save))
}
}
}
}
}
@Composable
private fun BookReplacementRuleList(
rules: List<ReaderWordReplacementRule>,
@StringRes emptyTextRes: Int,
onToggle: (ReaderWordReplacementRule, Boolean) -> Unit,
onEdit: (ReaderWordReplacementRule) -> Unit,
onDelete: (ReaderWordReplacementRule) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.tts_replacements_rules),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (rules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(emptyTextRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
rules.forEach { rule ->
val emptyLabel = stringResource(R.string.book_replacements_empty_replacement)
ListItem(
headlineContent = {
Text(
text = rule.summaryText(emptyLabel),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(rule.optionSummary())
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = rule.enabled,
onCheckedChange = { onToggle(rule, it) },
)
IconButton(onClick = { onEdit(rule) }) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit))
}
IconButton(onClick = { onDelete(rule) }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
}
}
},
)
}
}
}
private fun ReaderWordReplacementRule.summaryText(emptyLabel: String): String {
val replacement = to.ifBlank { emptyLabel }
return "$from -> $replacement"
}
@Composable
private fun ReaderWordReplacementRule.optionSummary(): String {
val regexLabel = stringResource(R.string.tts_replacements_chip_regex)
val plainTextLabel = stringResource(R.string.tts_replacements_plain_text)
val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word)
val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive)
val parts = buildList {
add(if (isRegex) regexLabel else plainTextLabel)
if (wholeWord) add(wholeWordLabel)
if (matchCase) add(caseSensitiveLabel)
}
return parts.joinToString(" - ")
}
private fun newBookReplacementRuleId(): String {
return "book_rule_${System.currentTimeMillis()}"
}

View file

@ -0,0 +1,51 @@
package com.aryan.reader
import com.aryan.reader.data.BookMetadata
import com.aryan.reader.data.RecentFileItem
internal fun RecentFileItem.needsRemoteEpubAnnotationMetadataGuard(): Boolean {
return type in EPUB_READER_FILE_TYPES &&
(bookmarksJson.isNullOrBlank() || highlightsJson.isNullOrBlank())
}
internal fun RecentFileItem.mergeRemoteEpubAnnotationMetadata(remote: BookMetadata?): RecentFileItem {
if (remote == null || remote.isDeleted || type !in EPUB_READER_FILE_TYPES || !remote.isEpubReaderMetadata()) {
return this
}
val nextBookmarks = if (bookmarksJson.isNullOrBlank() && remote.bookmarksJson.hasCloudAnnotationPayload()) {
remote.bookmarksJson
} else {
bookmarksJson
}
val nextHighlights = if (highlightsJson.isNullOrBlank() && remote.highlightsJson.hasCloudAnnotationPayload()) {
remote.highlightsJson
} else {
highlightsJson
}
if (nextBookmarks == bookmarksJson && nextHighlights == highlightsJson) return this
return copy(
bookmarksJson = nextBookmarks,
highlightsJson = nextHighlights
)
}
private fun BookMetadata.isEpubReaderMetadata(): Boolean {
val remoteType = runCatching { FileType.valueOf(type) }.getOrNull() ?: return false
return remoteType in EPUB_READER_FILE_TYPES
}
internal fun String?.hasCloudAnnotationPayload(): Boolean {
val normalized = this?.trim().orEmpty()
return normalized.isNotEmpty() && normalized != "[]"
}
internal fun annotationJsonEquivalentForNoop(existing: String?, incoming: String): Boolean {
val existingNormalized = existing?.trim().orEmpty()
val incomingNormalized = incoming.trim()
if (existingNormalized == incomingNormalized) return true
return existingNormalized.isAnnotationJsonEmpty() && incomingNormalized.isAnnotationJsonEmpty()
}
private fun String.isAnnotationJsonEmpty(): Boolean {
return isBlank() || this == "[]"
}

View file

@ -0,0 +1,75 @@
package com.aryan.reader
import java.io.File
internal data class AndroidPdfCloudSidecarState(
val hasInk: Boolean,
val inkTimestamp: Long,
val hasDeletedInk: Boolean = false,
val deletedInkTimestamp: Long = 0L,
val hasRichText: Boolean,
val richTextTimestamp: Long,
val hasLayout: Boolean,
val layoutTimestamp: Long,
val hasTextBoxes: Boolean,
val textBoxesTimestamp: Long,
val hasHighlights: Boolean,
val highlightsTimestamp: Long
) {
val hasAnnotationPayload: Boolean
get() = hasInk || hasDeletedInk || hasRichText || hasTextBoxes || hasHighlights
val annotationPayloadTimestamp: Long
get() = maxOf(
inkTimestamp.takeIf { hasInk } ?: 0L,
deletedInkTimestamp.takeIf { hasDeletedInk } ?: 0L,
richTextTimestamp.takeIf { hasRichText } ?: 0L,
textBoxesTimestamp.takeIf { hasTextBoxes } ?: 0L,
highlightsTimestamp.takeIf { hasHighlights } ?: 0L
)
val bundleTimestamp: Long
get() = if (hasAnnotationPayload) {
maxOf(annotationPayloadTimestamp, layoutTimestamp.takeIf { hasLayout } ?: 0L)
} else {
0L
}
}
internal fun shouldUploadLocalPdfCloudAnnotations(
localSidecars: AndroidPdfCloudSidecarState,
remoteHasAnnotations: Boolean,
remoteAnnotationModifiedTimestamp: Long
): Boolean {
return localSidecars.hasAnnotationPayload &&
(!remoteHasAnnotations || localSidecars.annotationPayloadTimestamp > remoteAnnotationModifiedTimestamp)
}
internal fun shouldDownloadRemotePdfCloudAnnotations(
localSidecars: AndroidPdfCloudSidecarState,
localAnnotationsShouldUpload: Boolean,
remoteHasAnnotations: Boolean,
remoteAnnotationModifiedTimestamp: Long
): Boolean {
if (localAnnotationsShouldUpload || !remoteHasAnnotations) return false
return !localSidecars.hasAnnotationPayload ||
remoteAnnotationModifiedTimestamp > localSidecars.annotationPayloadTimestamp
}
internal fun File?.hasSyncableCloudAnnotationPayload(): Boolean {
val file = this ?: return false
if (!file.isFile || file.length() <= 0L) return false
val trimmed = runCatching { file.readText().trim() }.getOrDefault("")
return trimmed.isNotBlank() && trimmed != "[]" && trimmed != "{}"
}
internal fun markPdfCloudAnnotationSidecarsSynced(timestamp: Long, vararg files: File?) {
if (timestamp <= 0L) return
files.forEach { file ->
if (file?.exists() == true) {
file.setLastModified(timestamp)
}
}
}
internal fun cloudPdfAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json"

View file

@ -0,0 +1,70 @@
package com.aryan.reader
import android.util.Log
import com.aryan.reader.data.BookMetadata
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp
import com.aryan.reader.data.effectiveReadingPositionModifiedTimestamp
import timber.log.Timber
internal const val CloudSyncTraceTag = "EpistemeCloudSync"
internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
internal fun logCloudSyncTrace(message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.d(CloudSyncTraceTag, text)
Timber.tag(CloudSyncTraceTag).d(text)
}
internal fun logCloudSyncError(error: Throwable, message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.e(CloudSyncTraceTag, text, error)
Timber.tag(CloudSyncTraceTag).e(error, text)
}
internal fun logCloudAnnotationSyncTrace(message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.d(CloudAnnotationSyncTraceTag, text)
Timber.tag(CloudAnnotationSyncTraceTag).d(text)
}
internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.e(CloudAnnotationSyncTraceTag, text, error)
Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
}
internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
"contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
"bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
"annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
"hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
val value = this ?: return "null"
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
}
internal fun String?.cloudSyncAnnotationSummary(): String {
val value = this?.trim() ?: return "null"
return when {
value.isEmpty() -> "blank"
value == "[]" -> "empty"
else -> "present(${value.length})"
}
}

View file

@ -2949,7 +2949,14 @@ private fun ThemeGridItem(
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
text = theme.name,
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable { onThemeSelected(theme.id) }
)
if (theme.isCustom && onEdit != null && onDelete != null) {
Spacer(modifier = Modifier.height(6.dp))

View file

@ -21,6 +21,7 @@ package com.aryan.reader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.InputStream
import java.security.MessageDigest
@ -50,9 +51,8 @@ object FileHasher {
}
hexString.toString()
} catch (e: Exception) {
// In a real app, you'd want to log this error
e.printStackTrace()
Timber.e(e, "Failed to calculate SHA-256 hash")
null
}
}
}
}

View file

@ -72,45 +72,27 @@ class FolderSyncWorker(
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString("synced_folders_list_json", null)
val folders = mutableListOf<Pair<String, Set<FileType>>>()
if (jsonString != null) {
try {
val array = org.json.JSONArray(jsonString)
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
val uri = obj.getString("uri")
val allowedFileTypes = mutableSetOf<FileType>()
if (obj.has("allowedFileTypes")) {
val typesArray = obj.getJSONArray("allowedFileTypes")
for (j in 0 until typesArray.length()) {
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
}
} else {
allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
}
folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
}
} catch (e: Exception) { Timber.e(e) }
} else {
val single = prefs.getString("synced_folder_uri", null)
if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
}
val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
val folders = SyncedFolderPrefs.decodeSyncedFolders(
jsonString = jsonString,
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
)
if (folders.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
return Result.success()
}
val enabledFolders = folders.filter { it.localSyncEnabled }
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
folders
enabledFolders
} else {
folders.filter { it.first == targetFolderUri }
enabledFolders.filter { it.uriString == targetFolderUri }
}
if (foldersToProcess.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
return Result.success()
}
@ -123,8 +105,8 @@ class FolderSyncWorker(
syncMutex.withLock {
var allSuccess = true
for ((uriString, allowedTypes) in foldersToProcess) {
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
for (folderConfig in foldersToProcess) {
val success = performSyncForFolder(folderConfig, isMetadataOnly)
if (!success) allSuccess = false
}
@ -132,13 +114,14 @@ class FolderSyncWorker(
try {
val array = org.json.JSONArray(jsonString)
val now = System.currentTimeMillis()
val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
if (obj.optString("uri") in processedUris) {
obj.put("lastScanTime", now)
}
}
prefs.edit { putString("synced_folders_list_json", array.toString()) }
prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
} catch (_: Exception) {}
}
@ -153,7 +136,9 @@ class FolderSyncWorker(
}
}
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
val folderUriString = folderConfig.uriString
val allowedFileTypes = folderConfig.allowedFileTypes
if (folderUriString.isBlank()) return true
val folderUri = folderUriString.toUri()
val folderStart = ReaderPerfLog.nowNanos()
@ -235,9 +220,10 @@ class FolderSyncWorker(
val nowMillis = System.currentTimeMillis()
val folder = SyncedFolder(
uriString = folderUriString,
name = documentTree.name ?: "Local Folder",
name = documentTree.name ?: folderConfig.name,
lastScanTime = nowMillis,
allowedFileTypes = allowedFileTypes
allowedFileTypes = allowedFileTypes,
localSyncEnabled = true
)
val sharedState = SharedReaderScreenState(
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
@ -564,7 +550,8 @@ class FolderSyncWorker(
lastPageIndex = lastPage,
readerPosition = readerPositionOrNull(),
readerBookmarks = parseReaderBookmarks(),
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
)
}
@ -613,8 +600,8 @@ class FolderSyncWorker(
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
progressPercentage = progressPercentage,
isRecent = isRecent,
isAvailable = true,
@ -637,6 +624,7 @@ class FolderSyncWorker(
originalDescription = originalDescription,
folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = existing?.tags.orEmpty()
)
}
@ -720,18 +708,12 @@ class FolderSyncWorker(
private fun isFolderStillLinked(folderUriString: String): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString("synced_folders_list_json", null)
if (jsonString != null) {
return try {
val array = org.json.JSONArray(jsonString)
(0 until array.length()).any { index ->
array.getJSONObject(index).optString("uri") == folderUriString
}
} catch (_: Exception) {
false
}
}
return prefs.getString("synced_folder_uri", null) == folderUriString
return SyncedFolderPrefs.isLocalSyncEnabled(
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
folderUriString = folderUriString,
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
)
}
private fun getFileType(name: String, mimeType: String?): FileType? {

View file

@ -3,8 +3,11 @@
package com.aryan.reader
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -27,10 +30,12 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@ -46,6 +51,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -80,37 +86,67 @@ fun FontsScreen(
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
// Dialog state
var showDeleteDialog by remember { mutableStateOf(false) }
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
var fontsPendingDelete by remember { mutableStateOf<List<CustomFontEntity>>(emptyList()) }
var showGoogleFontsSheet by remember { mutableStateOf(false) }
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
var selectedFontIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val pickFontLauncher = rememberFilePickerLauncher { uris ->
uris.firstOrNull()?.let { viewModel.importFont(it) }
val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts)
val fontMimeTypes = remember { supportedFontMimeTypes() }
val allFontIds = remember(fonts) { fonts.mapTo(mutableSetOf()) { it.id } }
val selectedFonts = remember(fonts, selectedFontIds) {
fonts.filter { it.id in selectedFontIds }
}
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
LaunchedEffect(fonts) {
selectedFontIds = selectedFontIds.intersect(allFontIds)
}
val fontMimeTypes = arrayOf(
"font/ttf", "font/otf", "font/woff2",
"application/x-font-ttf", "application/x-font-otf",
"application/font-woff2", "application/vnd.ms-opentype",
"application/x-font-opentype"
)
BackHandler(enabled = isFontSelectionMode) {
selectedFontIds = emptySet()
}
Scaffold(
modifier = Modifier.statusBarsPadding(),
topBar = {
CustomTopAppBar(
title = { Text(stringResource(R.string.custom_fonts)) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
if (isFontSelectionMode) {
ContextualTopAppBar(
selectedItemCount = selectedFonts.size,
onNavIconClick = { selectedFontIds = emptySet() },
onSelectAllClick = {
selectedFontIds = if (selectedFontIds.containsAll(allFontIds)) {
emptySet()
} else {
allFontIds
}
},
onDeleteClick = {
if (selectedFonts.isNotEmpty()) {
fontsPendingDelete = selectedFonts
}
}
}
)
)
} else {
CustomTopAppBar(
title = { Text(stringResource(R.string.custom_fonts)) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
IconButton(onClick = { selectedFontIds = allFontIds }) {
Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
}
}
}
)
}
},
floatingActionButton = {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp)
@ -139,7 +175,10 @@ fun FontsScreen(
Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs(
selectedSection = selectedSection,
onSectionChange = { selectedSection = it },
onSectionChange = {
selectedFontIds = emptySet()
selectedSection = it
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
)
@ -166,9 +205,13 @@ fun FontsScreen(
items(fonts, key = { it.id }) { font ->
FontListItem(
font = font,
isSelected = font.id in selectedFontIds,
isSelectionMode = isFontSelectionMode,
onSelectionToggle = {
selectedFontIds = selectedFontIds.toggle(font.id)
},
onDelete = {
fontToDelete = font
showDeleteDialog = true
fontsPendingDelete = listOf(font)
}
)
}
@ -208,17 +251,17 @@ fun FontsScreen(
}
}
if (showDeleteDialog && fontToDelete != null) {
DeleteFontConfirmationDialog(
fontName = fontToDelete!!.displayName,
if (fontsPendingDelete.isNotEmpty()) {
DeleteFontsConfirmationDialog(
fonts = fontsPendingDelete,
onConfirm = {
fontToDelete?.let { viewModel.deleteFont(it.id) }
showDeleteDialog = false
fontToDelete = null
val pendingIds = fontsPendingDelete.map { it.id }
viewModel.deleteFonts(pendingIds)
selectedFontIds = selectedFontIds - pendingIds.toSet()
fontsPendingDelete = emptyList()
},
onDismiss = {
showDeleteDialog = false
fontToDelete = null
fontsPendingDelete = emptyList()
}
)
}
@ -388,9 +431,13 @@ fun GoogleFontsBottomSheet(
}
// Existing unchanged components
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FontListItem(
font: CustomFontEntity,
isSelected: Boolean,
isSelectionMode: Boolean,
onSelectionToggle: () -> Unit,
onDelete: () -> Unit
) {
val customTypeface = remember(font.path) {
@ -402,8 +449,23 @@ fun FontListItem(
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (isSelectionMode) {
onSelectionToggle()
}
},
onLongClick = onSelectionToggle
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@ -411,17 +473,27 @@ fun FontListItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Text(
text = font.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error
)
if (!isSelectionMode) {
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error
)
}
}
}
@ -476,15 +548,32 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
}
@Composable
fun DeleteFontConfirmationDialog(
fontName: String,
fun DeleteFontsConfirmationDialog(
fonts: List<CustomFontEntity>,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val isSingleFont = fonts.size == 1
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_delete_font)) },
text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
title = {
Text(
if (isSingleFont) {
stringResource(R.string.dialog_delete_font)
} else {
stringResource(R.string.dialog_delete_fonts)
}
)
},
text = {
Text(
if (isSingleFont) {
stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName)
} else {
stringResource(R.string.dialog_delete_fonts_desc, fonts.size)
}
)
},
confirmButton = {
TextButton(
onClick = onConfirm,
@ -498,3 +587,7 @@ fun DeleteFontConfirmationDialog(
}
)
}
private fun Set<String>.toggle(id: String): Set<String> {
return if (id in this) this - id else this + id
}

View file

@ -130,6 +130,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -436,7 +437,7 @@ fun HomeScreen(
onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
}
@ -767,7 +768,8 @@ private fun RecentFilesGrid(
modifier = Modifier.size(16.dp)
)
}
}
},
modifier = Modifier.testTag("HomeTab_${tab.bookId}")
)
}
}
@ -817,6 +819,7 @@ fun RecentFileCard(
androidx.compose.material3.ElevatedCard(
modifier = modifier
.testTag("HomeRecentFileCard_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@ -1485,7 +1488,7 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.weight(1f))
// legal links
if (uiState.currentUser != null && !isOss) {
if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) {
val uriHandler = LocalUriHandler.current
val baseStyle = MaterialTheme.typography.labelMedium
var scaledTextStyle by remember { mutableStateOf(baseStyle) }

View file

@ -16,6 +16,7 @@ typealias ShelfType = com.aryan.reader.shared.ShelfType
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
internal val COMIC_ARCHIVE_FILE_TYPES = SharedFileCapabilities.comicArchiveTypes
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES

View file

@ -105,6 +105,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -120,6 +121,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@ -135,15 +137,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.decode.SvgDecoder
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.opds.OpdsAcquisition
import com.aryan.reader.opds.OpdsCatalog
import com.aryan.reader.opds.OpdsDownloadState
import com.aryan.reader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsRepository
import com.aryan.reader.opds.OpdsViewModel
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import com.aryan.reader.shared.opds.SharedOpdsLocalBookMatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@ -325,6 +331,7 @@ fun LibraryScreen(
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders,
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds,
lastFolderScanTime = uiState.lastFolderScanTime,
@ -590,6 +597,7 @@ fun LibraryScreenContent(
isRefreshing: Boolean,
syncedFolders: List<SyncedFolder>,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
@ -666,7 +674,8 @@ fun LibraryScreenContent(
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
.focusRequester(searchFocusRequester),
.focusRequester(searchFocusRequester)
.testTag("LibrarySearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@ -693,7 +702,10 @@ fun LibraryScreenContent(
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
}
Box {
TextButton(onClick = { showSortMenu = true }) {
TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("LibrarySortButton")
) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@ -822,7 +834,9 @@ fun LibraryScreenContent(
text = { Text(stringResource(R.string.fab_new_shelf)) },
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
onClick = onNewShelfClick,
modifier = Modifier.padding(16.dp)
modifier = Modifier
.padding(16.dp)
.testTag("LibraryNewShelfFab")
)
}
}
@ -888,6 +902,7 @@ fun LibraryScreenContent(
allRecentFiles = rawLibraryFiles,
onAddFolderClick = onSelectSyncFolderClick,
onRemoveFolderClick = onRemoveFolderClick,
onFolderLocalSyncChange = onFolderLocalSyncChange,
onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick,
@ -1140,7 +1155,8 @@ private fun ShelfDetailScreen(
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
.focusRequester(searchFocusRequester),
.focusRequester(searchFocusRequester)
.testTag("ShelfSearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@ -1193,7 +1209,10 @@ private fun ShelfDetailScreen(
},
actions = {
Box {
TextButton(onClick = { showSortMenu = true }) {
TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("ShelfSortButton")
) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@ -1375,7 +1394,10 @@ private fun AddBooksModeScreen(
},
actions = {
Box {
TextButton(onClick = { showSortMenu = true }) {
TextButton(
onClick = { showSortMenu = true },
modifier = Modifier.testTag("AddBooksSortButton")
) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@ -1581,6 +1603,7 @@ private fun ShelfListItem(
),
modifier = Modifier
.fillMaxWidth()
.testTag("ShelfItem_${shelf.id}")
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
@ -1659,6 +1682,7 @@ private fun LibraryListItem(
),
modifier = Modifier
.fillMaxWidth()
.testTag("LibraryBookItem_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@ -1929,12 +1953,15 @@ private fun FolderSyncScreen(
allRecentFiles: List<RecentFileItem>,
onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
isLoading: Boolean
) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
var disablingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
val folderStatsByUri = remember(allRecentFiles) {
allRecentFiles
.asSequence()
@ -1973,7 +2000,7 @@ private fun FolderSyncScreen(
) {
FilledTonalButton(
onClick = onScanNowClick,
enabled = !isLoading,
enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
@ -1988,7 +2015,7 @@ private fun FolderSyncScreen(
androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick,
enabled = !isLoading,
enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
@ -2016,6 +2043,13 @@ private fun FolderSyncScreen(
folder = folder,
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
onRemoveClick = onRemoveFolderClick,
onLocalSyncToggleClick = { selectedFolder ->
if (selectedFolder.localSyncEnabled) {
disablingFolder = selectedFolder
} else {
onFolderLocalSyncChange(selectedFolder, true, false)
}
},
onEditFiltersClick = { editingFolder = folder }
)
}
@ -2033,6 +2067,46 @@ private fun FolderSyncScreen(
onDismiss = { editingFolder = null }
)
}
disablingFolder?.let { folder ->
AlertDialog(
onDismissRequest = { disablingFolder = null },
title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
text = {
Text(
stringResource(
R.string.dialog_disable_folder_local_sync_desc,
LOCAL_FOLDER_SYNC_DATA_DIR
)
)
},
confirmButton = {
TextButton(
onClick = {
onFolderLocalSyncChange(folder, false, true)
disablingFolder = null
}
) {
Text(stringResource(R.string.action_disable_remove_sync_data))
}
},
dismissButton = {
Row {
TextButton(onClick = { disablingFolder = null }) {
Text(stringResource(R.string.action_cancel))
}
TextButton(
onClick = {
onFolderLocalSyncChange(folder, false, false)
disablingFolder = null
}
) {
Text(stringResource(R.string.action_disable_keep_sync_data))
}
}
}
)
}
}
private data class FolderFileStats(
@ -2050,6 +2124,7 @@ private fun FolderCard(
folder: SyncedFolder,
stats: FolderFileStats,
onRemoveClick: (SyncedFolder) -> Unit,
onLocalSyncToggleClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
@ -2075,13 +2150,22 @@ private fun FolderCard(
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = folder.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = folder.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!folder.localSyncEnabled) {
Text(
text = stringResource(R.string.folder_local_sync_disabled),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
Box {
@ -2096,6 +2180,21 @@ private fun FolderCard(
onEditFiltersClick(folder)
}
)
DropdownMenuItem(
text = {
Text(
if (folder.localSyncEnabled) {
stringResource(R.string.menu_disable_folder_local_sync)
} else {
stringResource(R.string.menu_enable_folder_local_sync)
}
)
},
onClick = {
showMenu = false
onLocalSyncToggleClick(folder)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_remove_folder)) },
onClick = {
@ -2377,6 +2476,7 @@ fun OpdsTab(
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState = uiState.downloadingState
val context = LocalContext.current
val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
@ -2601,6 +2701,7 @@ fun OpdsTab(
entry = entry,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[entry.id],
coverImageLoader = coverImageLoader,
onDownloadClick = { acquisition ->
opdsViewModel.downloadBook(
entry, acquisition, context
@ -2649,6 +2750,7 @@ fun OpdsTab(
entry = selectedEntry!!,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[selectedEntry!!.id],
coverImageLoader = coverImageLoader,
onDownloadFormat = { acquisition ->
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
onBookDownloaded(downloadedUri, selectedEntry!!.title)
@ -2776,6 +2878,29 @@ fun OpdsTab(
}
}
@Composable
private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
val context = LocalContext.current.applicationContext
val username = catalog?.username
val password = catalog?.password
val imageLoader = remember(context, username, password) {
ImageLoader.Builder(context)
.okHttpClient {
OpdsRepository.sharedHttpClient.newBuilder()
.authenticator(OpdsRepository.OpdsAuthenticator(username, password))
.build()
}
.components {
add(SvgDecoder.Factory())
}
.build()
}
DisposableEffect(imageLoader) {
onDispose { imageLoader.shutdown() }
}
return imageLoader
}
@Composable
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
Surface(
@ -2853,13 +2978,20 @@ fun OpdsBookCard(
entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsDownloadState?,
coverImageLoader: ImageLoader,
onDownloadClick: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
onClick: () -> Unit
) {
val libraryItem = remember(entry, localLibraryFiles) {
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
SharedOpdsLocalBookMatcher.find(
entry = entry,
books = localLibraryFiles,
title = { it.title },
displayName = { it.displayName },
path = { it.uriString }
)
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
@ -2878,6 +3010,7 @@ fun OpdsBookCard(
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
imageLoader = coverImageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 70.dp, height = 100.dp)
@ -2984,6 +3117,7 @@ fun OpdsBookDetailsSheet(
entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsDownloadState?,
coverImageLoader: ImageLoader,
onDownloadFormat: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
@ -2992,7 +3126,13 @@ fun OpdsBookDetailsSheet(
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val libraryItem = remember(entry, localLibraryFiles) {
localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
SharedOpdsLocalBookMatcher.find(
entry = entry,
books = localLibraryFiles,
title = { it.title },
displayName = { it.displayName },
path = { it.uriString }
)
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
@ -3012,6 +3152,7 @@ fun OpdsBookDetailsSheet(
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
imageLoader = coverImageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 110.dp, height = 160.dp)

File diff suppressed because it is too large Load diff

View file

@ -50,11 +50,20 @@ class MetadataExtractionWorker(
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val hasLegacy = prefs.contains("synced_folder_uri")
val hasNew = prefs.contains("synced_folders_list_json")
val linkedFolders = SyncedFolderPrefs.decodeSyncedFolders(
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
)
val enabledFolderUris = linkedFolders
.filter { it.localSyncEnabled }
.mapTo(mutableSetOf()) { it.uriString }
if (!hasLegacy && !hasNew) {
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
if (enabledFolderUris.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no linked folders with sync enabled")
return@withContext Result.success()
}
if (!sourceFolderUri.isNullOrBlank() && sourceFolderUri !in enabledFolderUris) {
ReaderPerfLog.d("MetadataWorker skipped: folder sync disabled folder=$sourceFolderUri")
return@withContext Result.success()
}
@ -62,7 +71,9 @@ class MetadataExtractionWorker(
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
sourceFolderUri = sourceFolderUri,
limit = METADATA_WORKER_BOOK_BATCH_SIZE
)
).filter { item ->
item.sourceFolderUri != null && item.sourceFolderUri in enabledFolderUris
}
if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")

View file

@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -28,20 +33,37 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.aryan.reader.shared.ui.ReaderMinimalSlider
import kotlin.math.roundToInt
private const val READER_PREFS_NAME = "reader_prefs"
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1
private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100
private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1
private const val MIN_CUSTOM_BRIGHTNESS = 0.01f
data class ReaderBrightnessSettings(
val useSystemBrightness: Boolean = true,
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
) {
val safeCustomBrightness: Float
get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
get() = normalizeReaderBrightness(customBrightness)
}
internal fun normalizeReaderBrightness(brightness: Float): Float {
val percent = (brightness * 100f).roundToInt()
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
return percent / 100f
}
internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float {
val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt()
val nextPercent = (currentPercent + percentDelta)
.coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
return nextPercent / 100f
}
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
@ -49,7 +71,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
return ReaderBrightnessSettings(
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
.let(::normalizeReaderBrightness)
)
}
@ -162,17 +184,9 @@ fun ReaderBrightnessSheet(
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = settings.safeCustomBrightness,
onValueChange = { brightness ->
onSettingsChange(
settings.copy(
useSystemBrightness = false,
customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
)
)
},
valueRange = MIN_CUSTOM_BRIGHTNESS..1f
ReaderBrightnessControl(
settings = settings,
onSettingsChange = onSettingsChange
)
Text(
text = stringResource(R.string.reader_brightness_custom_desc),
@ -186,6 +200,67 @@ fun ReaderBrightnessSheet(
}
}
@Composable
private fun ReaderBrightnessControl(
settings: ReaderBrightnessSettings,
onSettingsChange: (ReaderBrightnessSettings) -> Unit
) {
val brightness = settings.safeCustomBrightness
val canDecrease = brightness > MIN_CUSTOM_BRIGHTNESS
val canIncrease = brightness < 1f
fun updateBrightness(value: Float) {
onSettingsChange(
settings.copy(
useSystemBrightness = false,
customBrightness = normalizeReaderBrightness(value)
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
IconButton(
onClick = {
updateBrightness(stepReaderBrightness(brightness, -CUSTOM_BRIGHTNESS_STEP_PERCENT))
},
enabled = canDecrease,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Remove,
contentDescription = stringResource(R.string.content_desc_decrease),
modifier = Modifier.size(18.dp)
)
}
ReaderMinimalSlider(
value = brightness,
onValueChange = ::updateBrightness,
valueRange = MIN_CUSTOM_BRIGHTNESS..1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
thumbColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
updateBrightness(stepReaderBrightness(brightness, CUSTOM_BRIGHTNESS_STEP_PERCENT))
},
enabled = canIncrease,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.content_desc_increase),
modifier = Modifier.size(18.dp)
)
}
}
}
private fun Window.setReaderBrightness(brightness: Float) {
attributes = attributes.apply {
screenBrightness = brightness

View file

@ -56,6 +56,21 @@ internal fun shouldRenderReaderSlider(
isSearchActive: Boolean
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
internal fun readerSliderStepPage(
currentPage: Int,
delta: Int,
minPage: Int,
maxPage: Int
): Int {
val lowerBound = min(minPage, maxPage)
val upperBound = max(minPage, maxPage)
val nextPage = currentPage.toLong() + delta.toLong()
return nextPage
.coerceIn(lowerBound.toLong(), upperBound.toLong())
.toInt()
}
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
READER_SLIDER_TOGGLE_PREFIX + bookId

View file

@ -142,9 +142,12 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
import com.aryan.reader.shared.SharedLegalLinks
import com.aryan.reader.shared.SharedLegalProfile
import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.SharedText
import com.aryan.reader.shared.sharedLegalLinksForProfile
import com.aryan.reader.shared.ui.SharedMarkdownText
import timber.log.Timber
import java.text.SimpleDateFormat
@ -154,9 +157,25 @@ import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.roundToInt
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
internal fun legalLinksForAndroidFlavor(flavor: String = BuildConfig.FLAVOR): SharedLegalLinks {
val profile = if (flavor == "oss") SharedLegalProfile.OSS else SharedLegalProfile.STANDARD
return sharedLegalLinksForProfile(profile)
}
internal val PRIVACY_POLICY_URL: String get() = legalLinksForAndroidFlavor().privacyPolicyUrl
internal val TERMS_URL: String get() = legalLinksForAndroidFlavor().termsUrl
internal val LICENSES_URL: String get() = legalLinksForAndroidFlavor().licensesUrl
fun supportedFontMimeTypes(): Array<String> = arrayOf(
"font/ttf",
"font/otf",
"font/woff2",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-woff2",
"application/vnd.ms-opentype",
"application/x-font-opentype"
)
class CustomTabUriHandler(private val context: Context) : UriHandler {
override fun openUri(uri: String) {
@ -1237,53 +1256,55 @@ fun AboutDialog(onDismiss: () -> Unit) {
subtitle = stringResource(R.string.about_github_desc),
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
)
} else {
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Policy,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_privacy_policy),
subtitle = stringResource(R.string.about_privacy_desc),
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
)
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Gavel,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_terms_of_service),
subtitle = stringResource(R.string.about_terms_desc),
onClick = { uriHandler.openUri(TERMS_URL) }
)
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.FileOpen,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_licenses),
subtitle = stringResource(R.string.about_licenses_desc),
onClick = { uriHandler.openUri(LICENSES_URL) }
)
}
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Policy,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_privacy_policy),
subtitle = stringResource(R.string.about_privacy_desc),
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
)
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.Gavel,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_terms_of_service),
subtitle = stringResource(R.string.about_terms_desc),
onClick = { uriHandler.openUri(TERMS_URL) }
)
Spacer(modifier = Modifier.height(10.dp))
AboutInfoRow(
icon = {
Icon(
imageVector = Icons.Outlined.FileOpen,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary
)
},
text = stringResource(R.string.legal_licenses),
subtitle = stringResource(R.string.about_licenses_desc),
onClick = { uriHandler.openUri(LICENSES_URL) }
)
}
},
confirmButton = {

View file

@ -10,11 +10,13 @@ import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag
import com.aryan.reader.shared.toStablePositionCfi
fun FileType.toSharedFileType(): SharedFileType = this
@ -29,11 +31,21 @@ fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
return toSharedBookItem(
displayName = customName ?: displayName,
includeReaderAnnotations = true
)
}
private fun RecentFileItem.toSharedBookItem(
displayName: String,
includeReaderAnnotations: Boolean
): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
type = type,
displayName = customName ?: displayName,
displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
@ -53,13 +65,22 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
seriesName = seriesName,
seriesIndex = seriesIndex,
lastPageIndex = lastPage,
readerPosition = toSharedReaderLocatorOrNull(),
tags = tags.map { it.toSharedTag() },
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
readerHighlights = if (includeReaderAnnotations) {
EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
} else {
emptyList()
},
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
)
}
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
return toSharedBookItem().copy(displayName = displayName)
return toSharedBookItem(
displayName = displayName,
includeReaderAnnotations = false
)
}
fun SharedBookItem.toRecentFileItem(
@ -67,36 +88,49 @@ fun SharedBookItem.toRecentFileItem(
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): RecentFileItem {
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
return androidBooksById[id]?.copy(tags = resolvedTags)
?.copy(
val positionCfi = readerPosition?.toSharedPositionCfi()
androidBooksById[id]?.let { existing ->
val mappedLastChapterIndex = readerPosition?.chapterIndex ?: existing.lastChapterIndex
val mappedLastPositionCfi = positionCfi ?: existing.lastPositionCfi
val mappedLocatorBlockIndex = readerPosition?.blockIndex ?: existing.locatorBlockIndex
val mappedLocatorCharOffset = readerPosition?.charOffset ?: existing.locatorCharOffset
if (
existing.uriString == path &&
existing.type == type &&
existing.timestamp == timestamp &&
existing.coverImagePath == coverImagePath &&
existing.title == title &&
existing.author == author &&
existing.description == description &&
existing.originalTitle == originalTitle &&
existing.originalAuthor == originalAuthor &&
existing.originalSeriesName == originalSeriesName &&
existing.originalSeriesIndex == originalSeriesIndex &&
existing.originalDescription == originalDescription &&
existing.lastPage == lastPageIndex &&
existing.progressPercentage == progressPercentage &&
existing.isRecent == isRecent &&
existing.sourceFolderUri == sourceFolder &&
existing.fileSize == fileSize &&
existing.fileContentModifiedTimestamp == fileContentModifiedTimestamp &&
existing.seriesName == seriesName &&
existing.seriesIndex == seriesIndex &&
existing.folderTextMetadataParsed == folderTextMetadataParsed &&
existing.lastChapterIndex == mappedLastChapterIndex &&
existing.lastPositionCfi == mappedLastPositionCfi &&
existing.locatorBlockIndex == mappedLocatorBlockIndex &&
existing.locatorCharOffset == mappedLocatorCharOffset &&
existing.readingPositionModifiedTimestamp == readingPositionModifiedTimestamp &&
existing.tags == resolvedTags
) {
return existing
}
return existing.copy(
uriString = path,
type = type,
displayName = androidBooksById[id]?.displayName ?: displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
lastPage = lastPageIndex,
progressPercentage = progressPercentage,
isRecent = isRecent,
sourceFolderUri = sourceFolder,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed
)
?: RecentFileItem(
bookId = id,
uriString = path,
type = type,
displayName = displayName,
displayName = existing.displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
@ -116,8 +150,70 @@ fun SharedBookItem.toRecentFileItem(
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed,
lastChapterIndex = mappedLastChapterIndex,
lastPositionCfi = mappedLastPositionCfi,
locatorBlockIndex = mappedLocatorBlockIndex,
locatorCharOffset = mappedLocatorCharOffset,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = resolvedTags
)
}
return RecentFileItem(
bookId = id,
uriString = path,
type = type,
displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
lastPage = lastPageIndex,
progressPercentage = progressPercentage,
isRecent = isRecent,
sourceFolderUri = sourceFolder,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed,
lastChapterIndex = readerPosition?.chapterIndex,
lastPositionCfi = positionCfi,
locatorBlockIndex = readerPosition?.blockIndex,
locatorCharOffset = readerPosition?.charOffset,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = resolvedTags
)
}
private fun RecentFileItem.toSharedReaderLocatorOrNull(): SharedReaderLocator? {
if (
lastChapterIndex == null &&
lastPage == null &&
lastPositionCfi.isNullOrBlank() &&
locatorBlockIndex == null &&
locatorCharOffset == null
) {
return null
}
return SharedReaderLocator.fromLegacy(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = lastPage
).withFallbacks(
blockIndex = locatorBlockIndex,
charOffset = locatorCharOffset
)
}
private fun SharedReaderLocator.toSharedPositionCfi(): String? {
return toStablePositionCfi()
}
fun TagEntity.toSharedTag(): SharedTag {
@ -167,8 +263,16 @@ fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
fun ReaderScreenState.toSharedReaderScreenState(
rawBooks: List<RecentFileItem> = rawLibraryFiles,
dbTags: List<TagEntity> = allTags
dbTags: List<TagEntity> = allTags,
includeReaderAnnotations: Boolean = true
): SharedReaderScreenState {
fun RecentFileItem.toStateSharedBookItem(): SharedBookItem {
return toSharedBookItem(
displayName = customName ?: displayName,
includeReaderAnnotations = includeReaderAnnotations
)
}
return SharedReaderScreenState(
selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
@ -202,16 +306,16 @@ fun ReaderScreenState.toSharedReaderScreenState(
isSearchActive = isSearchActive,
isRefreshing = isRefreshing,
reflowProgress = reflowProgress,
recentBooks = recentFiles.map { it.toSharedBookItem() },
libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
recentBooks = recentFiles.map { it.toStateSharedBookItem() },
libraryBooks = allRecentFiles.map { it.toStateSharedBookItem() },
rawLibraryBooks = rawBooks.map { it.toStateSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
libraryFilters = libraryFilters,
recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
openTabs = openTabs.map { it.toSharedBookItem() },
openTabs = openTabs.map { it.toStateSharedBookItem() },
activeTabBookId = activeTabBookId,
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior,
@ -237,7 +341,14 @@ fun List<RecentFileItem>.withResolvedTags(
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
}
return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
return map { item ->
val resolvedTags = bookTagsMap[item.bookId].orEmpty()
if (item.tags == resolvedTags) {
item
} else {
item.copy(tags = resolvedTags)
}
}
}
fun SharedReaderScreenState.toAndroidReaderScreenState(
@ -246,8 +357,11 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): ReaderScreenState {
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
val mappedBooksById = LinkedHashMap<String, RecentFileItem>()
fun SharedBookItem.toAndroidBook(): RecentFileItem {
return toRecentFileItem(androidBooksById, tagEntitiesById)
return mappedBooksById.getOrPut(id) {
toRecentFileItem(androidBooksById, tagEntitiesById)
}
}
fun bookById(bookId: String): RecentFileItem? {
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
@ -260,7 +374,7 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
isAddingBooksToShelf = isAddingBooksToShelf,
contextualActionShelfIds = selectedShelfIds,
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
shelves = shelves.map { shelf -> shelf.toAndroidShelf { book -> book.toAndroidBook() } },
openTabs = openTabs.map { it.toAndroidBook() },
openTabIds = openTabIds,
activeTabBookId = activeTabBookId,
@ -272,13 +386,19 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
fun SharedShelf.toAndroidShelf(
androidBooksById: Map<String, RecentFileItem> = emptyMap(),
tagEntitiesById: Map<String, TagEntity> = emptyMap()
): Shelf {
return toAndroidShelf { it.toRecentFileItem(androidBooksById, tagEntitiesById) }
}
private fun SharedShelf.toAndroidShelf(
resolveBook: (SharedBookItem) -> RecentFileItem
): Shelf {
return Shelf(
id = id,
name = name,
type = type,
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
books = books.map(resolveBook),
directBooks = directBooks.map(resolveBook),
parentShelfId = parentShelfId,
childShelfIds = childShelfIds,
depth = depth,

View file

@ -0,0 +1,108 @@
package com.aryan.reader
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
internal object SyncedFolderPrefs {
const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
const val KEY_LEGACY_SYNCED_FOLDER_URI = "synced_folder_uri"
const val KEY_LEGACY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
fun decodeSyncedFolders(
jsonString: String?,
legacyUri: String?,
legacyLastScanTime: Long = 0L,
legacyNameResolver: (String) -> String = { it },
syncableTypes: Set<FileType> = ANDROID_SYNCABLE_FILE_TYPES
): List<SyncedFolder> {
if (jsonString == null) {
return legacyUri
?.takeIf { it.isNotBlank() }
?.let { uri ->
listOf(
SyncedFolder(
uriString = uri,
name = legacyNameResolver(uri),
lastScanTime = legacyLastScanTime,
allowedFileTypes = syncableTypes,
localSyncEnabled = true
)
)
}
.orEmpty()
}
return try {
val array = JSONArray(jsonString)
buildList {
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
val uri = obj.optString("uri").takeIf { it.isNotBlank() }
if (uri == null) continue
val name = obj.optString("name").takeIf { it.isNotBlank() } ?: legacyNameResolver(uri)
add(
SyncedFolder(
uriString = uri,
name = name,
lastScanTime = obj.optLong("lastScanTime", 0L),
allowedFileTypes = decodeAllowedFileTypes(obj, syncableTypes),
localSyncEnabled = obj.optBoolean("localSyncEnabled", true)
)
)
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse synced folders JSON")
emptyList()
}
}
fun encodeSyncedFolders(
folders: List<SyncedFolder>,
syncableTypes: Set<FileType> = ANDROID_SYNCABLE_FILE_TYPES
): String {
val jsonArray = JSONArray()
folders.forEach { folder ->
val obj = JSONObject()
obj.put("uri", folder.uriString)
obj.put("name", folder.name)
obj.put("lastScanTime", folder.lastScanTime)
obj.put("localSyncEnabled", folder.localSyncEnabled)
val typesArray = JSONArray()
folder.allowedFileTypes
.filter { it in syncableTypes }
.forEach { typesArray.put(it.name) }
obj.put("allowedFileTypes", typesArray)
jsonArray.put(obj)
}
return jsonArray.toString()
}
fun isLocalSyncEnabled(
jsonString: String?,
legacyUri: String?,
folderUriString: String,
syncableTypes: Set<FileType> = ANDROID_SYNCABLE_FILE_TYPES
): Boolean {
return decodeSyncedFolders(
jsonString = jsonString,
legacyUri = legacyUri,
syncableTypes = syncableTypes
).firstOrNull { it.uriString == folderUriString }?.localSyncEnabled == true
}
private fun decodeAllowedFileTypes(
obj: JSONObject,
syncableTypes: Set<FileType>
): Set<FileType> {
if (!obj.has("allowedFileTypes")) return syncableTypes
val typesArray = obj.optJSONArray("allowedFileTypes") ?: return syncableTypes
return buildSet {
for (i in 0 until typesArray.length()) {
val type = runCatching { FileType.valueOf(typesArray.getString(i)) }.getOrNull()
if (type != null && type in syncableTypes) add(type)
}
}
}
}

View file

@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class,
BookTagCrossRef::class
],
version = 22,
version = 23,
exportSchema = false
)
@TypeConverters(FileTypeConverter::class)
@ -288,6 +288,25 @@ abstract class AppDatabase : RoomDatabase() {
}
}
val MIGRATION_22_23 = object : Migration(22, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN readingPositionModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
db.execSQL("""
UPDATE recent_files
SET readingPositionModifiedTimestamp = lastModifiedTimestamp
WHERE lastModifiedTimestamp > 0
AND (
lastChapterIndex IS NOT NULL OR
lastPage IS NOT NULL OR
lastPositionCfi IS NOT NULL OR
locatorBlockIndex IS NOT NULL OR
locatorCharOffset IS NOT NULL OR
COALESCE(progressPercentage, 0) > 0
)
""")
}
}
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
@ -301,7 +320,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
MIGRATION_20_21, MIGRATION_21_22
MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23
)
.fallbackToDestructiveMigration(false)
.build()

View file

@ -8,6 +8,7 @@ import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import com.aryan.reader.ReaderPerfLog
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
import com.aryan.reader.shared.localFolderSyncMetadataFileName
@ -21,7 +22,7 @@ import timber.log.Timber
object LocalSyncUtils {
private const val TAG = "FolderSync"
private const val ANNOTATION_SUFFIX = "_annotations"
private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData"
private const val SYNC_SUBFOLDER_NAME = LOCAL_FOLDER_SYNC_DATA_DIR
private data class SyncFileEntry(
val name: String,
@ -638,6 +639,21 @@ object LocalSyncUtils {
}
}
suspend fun deleteSyncDataFolder(
context: Context,
sourceFolderUri: Uri
): Boolean = withContext(Dispatchers.IO) {
try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext false
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext true
if (!syncDir.isDirectory) return@withContext false
syncDir.delete()
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to delete sync data folder")
false
}
}
suspend fun getAllFolderMetadata(
context: Context,
sourceFolderUri: Uri

View file

@ -33,7 +33,7 @@ interface RecentFileDao {
@Upsert
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileSummary>>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
@ -45,7 +45,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
@ -75,10 +75,10 @@ interface RecentFileDao {
@Query("DELETE FROM recent_files")
suspend fun clearAll()
@Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
@Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateEpubReadingPosition(bookId: String, cfi: String?, chapterIndex: Int, blockIndex: Int, charOffset: Int, progress: Float, timestamp: Long)
@Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
@Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updatePdfReadingPosition(bookId: String, page: Int, progress: Float, timestamp: Long)
@Query("UPDATE recent_files SET bookmarks = :bookmarksJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")

View file

@ -62,7 +62,8 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null,
@ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L
)
data class RecentFileSummary(
@ -96,5 +97,6 @@ data class RecentFileSummary(
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
@ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null,
@ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L
)

View file

@ -57,9 +57,25 @@ data class RecentFileItem(
val originalDescription: String? = null,
val folderTextMetadataParsed: Boolean = false,
val folderCoverMetadataParsed: Boolean = false,
val readingPositionModifiedTimestamp: Long = 0L,
val tags: List<TagEntity> = emptyList()
)
fun RecentFileItem.hasReadingPositionForSync(): Boolean {
return lastChapterIndex != null ||
lastPage != null ||
!lastPositionCfi.isNullOrBlank() ||
locatorBlockIndex != null ||
locatorCharOffset != null ||
(progressPercentage ?: 0f) > 0f
}
fun RecentFileItem.effectiveReadingPositionModifiedTimestamp(): Long {
return readingPositionModifiedTimestamp.takeIf { it > 0L }
?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() }
?: 0L
}
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
@ -96,7 +112,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
originalSeriesIndex = this.originalSeriesIndex,
originalDescription = this.originalDescription,
folderTextMetadataParsed = this.folderTextMetadataParsed,
folderCoverMetadataParsed = this.folderCoverMetadataParsed
folderCoverMetadataParsed = this.folderCoverMetadataParsed,
readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp
)
}
@ -136,7 +153,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
originalDescription = this.originalDescription ?: this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed,
folderCoverMetadataParsed = this.folderCoverMetadataParsed
folderCoverMetadataParsed = this.folderCoverMetadataParsed,
readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp()
)
}
@ -156,6 +174,7 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
isRecent = this.isRecent,
isDeleted = this.isDeleted,
lastModifiedTimestamp = this.lastModifiedTimestamp,
readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp(),
bookmarksJson = this.bookmarksJson,
hasAnnotations = false,
customName = this.customName,
@ -172,6 +191,27 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
)
}
fun BookMetadata.hasReadingPositionForSync(): Boolean {
return lastChapterIndex != null ||
lastPage != null ||
!lastPositionCfi.isNullOrBlank() ||
locatorBlockIndex != null ||
locatorCharOffset != null ||
(progressPercentage ?: 0f) > 0f
}
fun BookMetadata.effectiveReadingPositionModifiedTimestamp(): Long {
return readingPositionModifiedTimestamp.takeIf { it > 0L }
?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() }
?: 0L
}
fun BookMetadata.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long = 0L): Long {
return sidecarModifiedTimestamp.takeIf { it > 0L }
?: annotationModifiedTimestamp.takeIf { it > 0L }
?: 0L
}
fun BookMetadata.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
@ -203,7 +243,8 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
originalDescription = this.originalDescription
originalDescription = this.originalDescription,
readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp()
)
}
@ -241,6 +282,7 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
originalDescription = this.originalDescription
originalDescription = this.originalDescription,
readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp
)
}

View file

@ -27,9 +27,15 @@ import android.net.Uri
import androidx.core.net.toUri
import com.aryan.reader.FileType
import com.aryan.reader.ReaderPerfLog
import com.aryan.reader.SyncedFolderPrefs
import com.aryan.reader.cloudSyncPreview
import com.aryan.reader.cloudSyncTraceSummary
import com.aryan.reader.logCloudAnnotationSyncTrace
import com.aryan.reader.logCloudSyncTrace
import com.aryan.reader.scaledToCanvasLimit
import timber.log.Timber
import com.aryan.reader.BookImporter
import com.aryan.reader.cloudSyncAnnotationSummary
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.pdf.PdfRichTextRepository
import com.aryan.reader.epub.ImportedFileCache
@ -189,7 +195,20 @@ class RecentFilesRepository(private val context: Context) {
!embeddedMetadataFileChanged &&
existingItem.hasEmbeddedMetadataChanges()
item.toRecentFileEntity().copy(
val incomingEntity = item.toRecentFileEntity()
val incomingReadingTimestamp = item.effectiveReadingPositionModifiedTimestamp()
val existingReadingTimestamp = existingItem.readingPositionModifiedTimestamp.takeIf { it > 0L }
?: existingItem.lastModifiedTimestamp.takeIf {
existingItem.lastChapterIndex != null ||
existingItem.lastPage != null ||
!existingItem.lastPositionCfi.isNullOrBlank() ||
existingItem.locatorBlockIndex != null ||
existingItem.locatorCharOffset != null ||
(existingItem.progressPercentage ?: 0f) > 0f
}
?: 0L
val incomingReadingWins = incomingReadingTimestamp >= existingReadingTimestamp
incomingEntity.copy(
uriString = existingItem.uriString ?: item.uriString,
isAvailable = existingItem.isAvailable || item.isAvailable,
coverImagePath = if (folderFileChanged) {
@ -211,13 +230,13 @@ class RecentFilesRepository(private val context: Context) {
} else {
item.author ?: existingItem.author
},
lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
lastPage = item.lastPage ?: existingItem.lastPage,
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
locatorBlockIndex = item.locatorBlockIndex ?: existingItem.locatorBlockIndex,
locatorCharOffset = item.locatorCharOffset ?: existingItem.locatorCharOffset,
lastChapterIndex = if (incomingReadingWins) item.lastChapterIndex ?: existingItem.lastChapterIndex else existingItem.lastChapterIndex,
lastPage = if (incomingReadingWins) item.lastPage ?: existingItem.lastPage else existingItem.lastPage,
lastPositionCfi = if (incomingReadingWins) item.lastPositionCfi ?: existingItem.lastPositionCfi else existingItem.lastPositionCfi,
locatorBlockIndex = if (incomingReadingWins) item.locatorBlockIndex ?: existingItem.locatorBlockIndex else existingItem.locatorBlockIndex,
locatorCharOffset = if (incomingReadingWins) item.locatorCharOffset ?: existingItem.locatorCharOffset else existingItem.locatorCharOffset,
bookmarks = item.bookmarksJson ?: existingItem.bookmarks,
progressPercentage = item.progressPercentage ?: existingItem.progressPercentage,
progressPercentage = if (incomingReadingWins) item.progressPercentage ?: existingItem.progressPercentage else existingItem.progressPercentage,
isRecent = item.isRecent,
isDeleted = item.isDeleted,
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
@ -259,13 +278,26 @@ class RecentFilesRepository(private val context: Context) {
item.folderCoverMetadataParsed
} else {
item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed
}
},
readingPositionModifiedTimestamp = maxOf(incomingReadingTimestamp, existingReadingTimestamp)
)
} else {
item.toRecentFileEntity()
}
Timber.d("SyncDebug: -> Final entity to insert: uri='${entityToInsert.uriString}', isAvailable=${entityToInsert.isAvailable}, isDeleted=${entityToInsert.isDeleted}, isRecent=${entityToInsert.isRecent}")
logCloudSyncTrace {
"android.db.upsert book=${item.bookId} ${item.cloudSyncTraceSummary("incoming")} " +
"existingTs=${existingItem?.lastModifiedTimestamp} existingPage=${existingItem?.lastPage} " +
"existingReadTs=${existingItem?.readingPositionModifiedTimestamp} " +
"existingChapter=${existingItem?.lastChapterIndex} finalTs=${entityToInsert.lastModifiedTimestamp} " +
"finalReadTs=${entityToInsert.readingPositionModifiedTimestamp} " +
"finalPage=${entityToInsert.lastPage} finalChapter=${entityToInsert.lastChapterIndex} " +
"finalBlock=${entityToInsert.locatorBlockIndex} finalChar=${entityToInsert.locatorCharOffset} " +
"finalProgress=${entityToInsert.progressPercentage} finalCfi=${entityToInsert.lastPositionCfi.cloudSyncPreview()} " +
"finalBookmarks=${entityToInsert.bookmarks.cloudSyncAnnotationSummary()} " +
"finalHighlights=${entityToInsert.highlights.cloudSyncAnnotationSummary()}"
}
recentFileDao.insertOrUpdateFile(entityToInsert)
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
}
@ -328,6 +360,11 @@ class RecentFilesRepository(private val context: Context) {
val folderUriString = entity.sourceFolderUri
if (folderUriString != null) {
if (!isLocalFolderSyncEnabled(folderUriString)) {
Timber.d("SyncDebug: Folder sync disabled for $folderUriString. Skipping metadata sidecar.")
return@withContext
}
val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f)
val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]"
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
@ -385,6 +422,10 @@ class RecentFilesRepository(private val context: Context) {
Timber.tag("FolderAnnotationSync").w("sourceFolderUri is null for bookId: $bookId")
return@withContext
}
if (!isLocalFolderSyncEnabled(folderUriString)) {
Timber.tag("FolderAnnotationSync").d("Folder sync disabled for $folderUriString. Skipping annotation sidecar.")
return@withContext
}
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
@ -466,12 +507,20 @@ class RecentFilesRepository(private val context: Context) {
)
}
suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
suspend fun importAnnotationBundle(
bookId: String,
jsonString: String,
lastModifiedTimestamp: Long? = null
) = withContext(Dispatchers.IO) {
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
try {
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
logCloudAnnotationSyncTrace {
"android.repository.import_bundle book=$bookId remoteTs=${lastModifiedTimestamp ?: 0L} " +
"rawBytes=${jsonString.length} keys=${bundle.keys().asSequence().toList()}"
}
Timber.d(
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
@ -482,12 +531,22 @@ class RecentFilesRepository(private val context: Context) {
file.parentFile?.mkdirs()
val contentStr = bundle.get(key).toString()
file.writeText(contentStr)
lastModifiedTimestamp?.takeIf { it > 0L }?.let(file::setLastModified)
logCloudAnnotationSyncTrace {
"android.repository.import_write key=$key book=$bookId bytes=${contentStr.length} " +
"path=${file.absolutePath.cloudSyncPreview(140)} ts=${file.lastModified()}"
}
if (key == "text") {
Timber.d(
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
)
}
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
} else if (file != null) {
logCloudAnnotationSyncTrace {
"android.repository.import_missing_key key=$key book=$bookId " +
"path=${file.absolutePath.cloudSyncPreview(140)} exists=${file.exists()}"
}
}
}
@ -496,6 +555,10 @@ class RecentFilesRepository(private val context: Context) {
context.filesDir, "annotations/annotation_$bookId.json"
)
writeSafe("ink", inkFile)
writeSafe(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
File(context.filesDir, "annotations/deleted_annotation_$bookId.json")
)
// 2. Text
writeSafe("text", pdfRichTextRepository.getFileForSync(bookId))
@ -606,6 +669,15 @@ class RecentFilesRepository(private val context: Context) {
Timber.d("Detached all folder books. They are now standard local files.")
}
private fun isLocalFolderSyncEnabled(folderUriString: String): Boolean {
val prefs = context.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
return SyncedFolderPrefs.isLocalSyncEnabled(
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
folderUriString = folderUriString
)
}
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
@ -618,6 +690,9 @@ class RecentFilesRepository(private val context: Context) {
val currentTime = System.currentTimeMillis()
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime")
logCloudSyncTrace {
"android.repository.pdf_position_update book=${item.bookId} page=$page progress=$progress ts=$currentTime"
}
} else {
Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString")
}

View file

@ -15,8 +15,8 @@ import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.ZipInputStream
import javax.xml.parsers.DocumentBuilderFactory
data class CalibreBundleResult(
val internalBookUri: Uri,
@ -89,7 +89,7 @@ object CalibreBundleExtractor {
if (tempBookFile != null && opfData != null && extractedType != null) {
val finalBookFile = bookImporter.createBookFile("$bookId.$ext")
tempBookFile!!.renameTo(finalBookFile)
moveExtractedBook(tempBookFile!!, finalBookFile)
var coverPath: String? = null
if (coverBytes != null) {
@ -106,7 +106,7 @@ object CalibreBundleExtractor {
var seriesIndex: Double? = null
try {
val factory = DocumentBuilderFactory.newInstance()
val factory = secureDocumentBuilderFactory()
val builder = factory.newDocumentBuilder()
val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8)))
val metadataNodes = document.getElementsByTagName("metadata")
@ -159,8 +159,25 @@ object CalibreBundleExtractor {
} catch (e: Exception) {
Timber.e(e, "Failed to process zip bundle")
} finally {
tempBookFile?.delete() // Cleanup if parsing failed midway
tempBookFile?.takeIf { it.exists() }?.delete()
}
return@withContext null
}
}
private fun moveExtractedBook(tempBookFile: File, finalBookFile: File) {
finalBookFile.parentFile?.mkdirs()
if (finalBookFile.exists() && !finalBookFile.delete()) {
throw IOException("Could not replace existing book file: ${finalBookFile.absolutePath}")
}
if (tempBookFile.renameTo(finalBookFile)) return
tempBookFile.inputStream().use { input ->
FileOutputStream(finalBookFile).use { output ->
input.copyTo(output)
}
}
if (!finalBookFile.isFile) {
throw IOException("Could not move extracted book to: ${finalBookFile.absolutePath}")
}
}
}

View file

@ -33,5 +33,10 @@ data class EpubChapter @OptIn(ExperimentalSerializationApi::class) constructor(
@ProtoNumber(5) val plainTextContent: String,
@ProtoNumber(6) val htmlContent: String,
@ProtoNumber(7) val depth: Int = 0,
@ProtoNumber(8) val isInToc: Boolean = true
)
@ProtoNumber(8) val isInToc: Boolean = true,
@ProtoNumber(9) val plainTextLength: Int = plainTextContent.length
)
fun EpubChapter.plainTextCharacterCount(): Int {
return maxOf(plainTextLength, plainTextContent.length)
}

View file

@ -23,22 +23,53 @@ import org.w3c.dom.Document
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.NodeList
import java.io.File
import java.io.InputStream
import javax.xml.XMLConstants
import javax.xml.parsers.DocumentBuilderFactory
fun parseXMLFile(inputSteam: InputStream): Document? =
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputSteam)
secureDocumentBuilderFactory().newDocumentBuilder().parse(inputSteam)
fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream())
fun String.asFileName(): String = this.replace("/", "_")
internal fun secureDocumentBuilderFactory(): DocumentBuilderFactory {
return DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = false
setFeatureSafely(XMLConstants.FEATURE_SECURE_PROCESSING, true)
setFeatureSafely("http://apache.org/xml/features/disallow-doctype-decl", true)
setFeatureSafely("http://xml.org/sax/features/external-general-entities", false)
setFeatureSafely("http://xml.org/sax/features/external-parameter-entities", false)
setFeatureSafely("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
runCatching { isXIncludeAware = false }
runCatching { isExpandEntityReferences = false }
}
}
private fun DocumentBuilderFactory.setFeatureSafely(name: String, value: Boolean) {
runCatching { setFeature(name, value) }
}
internal fun safeFileInRoot(root: File, childPath: String): File? {
val rootFile = runCatching { root.canonicalFile }.getOrNull() ?: return null
val targetFile = runCatching { File(rootFile, childPath).canonicalFile }.getOrNull() ?: return null
return targetFile.takeIf { it.isInsideOrSame(rootFile) }
}
internal fun File.isInsideOrSame(root: File): Boolean {
val rootPath = runCatching { root.canonicalFile.path }.getOrNull() ?: return false
val targetPath = runCatching { canonicalFile.path }.getOrNull() ?: return false
return targetPath == rootPath || targetPath.startsWith(rootPath + File.separator)
}
fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0)
fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag }
fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag }
fun Node.getAttributeValue(attribute: String): String? =
attributes?.getNamedItem(attribute)?.textContent
val NodeList.elements get() = (0..length).asSequence().mapNotNull { item(it) as? Element }
val NodeList.elements get() = (0 until length).asSequence().mapNotNull { item(it) as? Element }
val Node.childElements get() = childNodes.elements

View file

@ -12,6 +12,7 @@ import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.security.MessageDigest
import java.util.zip.ZipInputStream
class Fb2Parser(private val context: Context) {
@ -210,7 +211,12 @@ class Fb2Parser(private val context: Context) {
if (!inBody) {
if (coverImageId == null) coverImageId = id
} else {
currentChapterHtml.append("<img src=\"$id\" />")
val safeImageName = safeResourceFileName(id)
if (safeImageName != null) {
currentChapterHtml.append("<img src=\"$safeImageName\" />")
} else {
Timber.w("Skipping unsafe FB2 image reference: $id")
}
}
}
}
@ -220,12 +226,23 @@ class Fb2Parser(private val context: Context) {
val base64Data = parser.nextText()
try {
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
val safeId = safeResourceFileName(id)
if (parseContent) {
val imgFile = File(extractionDir, id)
FileOutputStream(imgFile).use { it.write(bytes) }
if (safeId != null) {
val imgFile = safeFileInRoot(extractionDir, safeId)
if (imgFile != null) {
FileOutputStream(imgFile).use { it.write(bytes) }
} else {
Timber.w("Skipping unsafe FB2 binary path: $id")
}
} else {
Timber.w("Skipping unsafe FB2 binary id: $id")
}
}
images.add(EpubImage(absPath = id))
if (safeId != null) {
images.add(EpubImage(absPath = safeId))
}
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
coverBytes = bytes
@ -326,4 +343,30 @@ class Fb2Parser(private val context: Context) {
}
}
}
private fun safeResourceFileName(id: String): String? {
val rawName = id.substringAfterLast('/').substringAfterLast('\\').trim()
if (rawName.isBlank() || rawName == "." || rawName == "..") return null
val extension = rawName.substringAfterLast('.', missingDelimiterValue = "")
.takeIf { it.isNotBlank() && it.length <= 12 }
?.replace(Regex("[^A-Za-z0-9]"), "")
.orEmpty()
val baseName = rawName.substringBeforeLast('.', rawName)
.replace(Regex("[^A-Za-z0-9._-]+"), "_")
.trim('.', '_', '-')
.ifBlank { "image" }
.take(48)
val suffix = sha256Hex(id).take(12)
return if (extension.isBlank()) {
"${baseName}_$suffix"
} else {
"${baseName}_$suffix.$extension"
}
}
private fun sha256Hex(value: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }
}
}

View file

@ -184,9 +184,13 @@ class OdtParser(private val context: Context) {
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
else -> {
if (entry.name !in ignoredFiles) {
val extractedFile = File(extractionDir, entry.name)
extractedFile.parentFile?.mkdirs()
FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
val extractedFile = safeFileInRoot(extractionDir, entry.name)
if (extractedFile != null) {
extractedFile.parentFile?.mkdirs()
FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
} else {
Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}")
}
}
}
}
@ -371,10 +375,18 @@ class OdtParser(private val context: Context) {
if (isFlat) {
try {
val bytes = Base64.decode(base64Builder.toString(), Base64.DEFAULT)
val imgName = currentImageHref?.substringAfterLast("/") ?: "${UUID.randomUUID()}.png"
val imgFile = File(extractionDir, imgName)
FileOutputStream(imgFile).use { it.write(bytes) }
currentChapterHtml.append("<img src=\"${imgName}\" />")
val imgName = currentImageHref
?.substringAfterLast("/")
?.substringAfterLast("\\")
?.takeIf { it.isNotBlank() }
?: "${UUID.randomUUID()}.png"
val imgFile = safeFileInRoot(extractionDir, imgName)
if (imgFile != null) {
FileOutputStream(imgFile).use { it.write(bytes) }
currentChapterHtml.append("<img src=\"${imgName}\" />")
} else {
Timber.w("Skipping unsafe FODT image path: $imgName")
}
} catch (e: Exception) {
Timber.e(e, "Failed to decode FODT image")
}

View file

@ -56,6 +56,8 @@ class SingleFileImporter(private val context: Context) {
private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000
private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000
private const val MAX_HTML_INLINE_CSS_CHARS = 256_000
private const val MAX_SINGLE_FILE_METADATA_BYTES = 2L * 1024L * 1024L
private const val BOOK_METADATA_FILE = "book_metadata.json"
private const val PAGE_BREAK_MARKER = "<page-break></page-break>"
}
@ -68,6 +70,62 @@ class SingleFileImporter(private val context: Context) {
private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
private fun metadataFile(extractionDir: File): File = File(extractionDir, BOOK_METADATA_FILE)
private fun EpubBook.lightweightSingleFileCache(): EpubBook {
val cacheChapters = chapters.map { chapter ->
chapter.copy(
plainTextContent = "",
htmlContent = ""
)
}
return copy(
coverImage = null,
chapters = cacheChapters,
chaptersForPagination = cacheChapters
)
}
private fun readCachedSingleFileBook(metadataFile: File, extractionDir: File, tag: String): EpubBook? {
if (!metadataFile.exists()) return null
if (metadataFile.length() > MAX_SINGLE_FILE_METADATA_BYTES) {
Timber.w(
"Ignoring oversized $tag metadata cache (${metadataFile.length()} bytes). " +
"The file will be reparsed with lightweight metadata."
)
runCatching { metadataFile.delete() }
return null
}
return try {
val decodedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
val cacheChapters = decodedBook.chapters.map { it.copy(htmlContent = "") }
decodedBook.copy(
chapters = cacheChapters,
chaptersForPagination = cacheChapters,
extractionBasePath = extractionDir.absolutePath
).takeIf { it.hasReadableExtractedContent() }
} catch (e: OutOfMemoryError) {
Timber.e(e, "Failed to load cached $tag metadata without exhausting memory")
runCatching { metadataFile.delete() }
null
} catch (e: Exception) {
Timber.e(e, "Failed to load cached $tag, parsing again")
null
}
}
private fun writeSingleFileMetadata(metadataFile: File, book: EpubBook, tag: String) {
try {
metadataFile.writeText(jsonSerializer.encodeToString(book.lightweightSingleFileCache()))
} catch (e: OutOfMemoryError) {
Timber.e(e, "Failed to cache lightweight $tag metadata without exhausting memory")
runCatching { metadataFile.delete() }
} catch (e: Exception) {
Timber.e(e, "Failed to cache $tag metadata")
}
}
suspend fun importSingleFile(
inputStream: InputStream,
type: FileType,
@ -195,17 +253,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
val metadataFile = metadataFile(extractionDir)
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached MD, parsing again")
}
readCachedSingleFileBook(metadataFile, extractionDir, "MD")?.let { cachedBook ->
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@ -299,11 +351,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
try {
metadataFile.writeText(jsonSerializer.encodeToString(book))
} catch (e: Exception) {
Timber.e(e, "Failed to cache MD metadata")
}
writeSingleFileMetadata(metadataFile, book, "MD")
return@withContext book
}
@ -331,17 +379,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
val metadataFile = metadataFile(extractionDir)
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached TXT, parsing again")
}
readCachedSingleFileBook(metadataFile, extractionDir, "TXT")?.let { cachedBook ->
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@ -462,11 +504,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
try {
metadataFile.writeText(jsonSerializer.encodeToString(book))
} catch (e: Exception) {
Timber.e(e, "Failed to cache TXT metadata")
}
writeSingleFileMetadata(metadataFile, book, "TXT")
return@withContext book
}
@ -494,17 +532,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
val metadataFile = metadataFile(extractionDir)
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached HTML, parsing again")
}
readCachedSingleFileBook(metadataFile, extractionDir, "HTML")?.let { cachedBook ->
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@ -699,11 +731,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
try {
metadataFile.writeText(jsonSerializer.encodeToString(book))
} catch (e: Exception) {
Timber.e(e, "Failed to cache HTML metadata")
}
writeSingleFileMetadata(metadataFile, book, "HTML")
return@withContext book
}
@ -783,17 +811,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
val metadataFile = metadataFile(extractionDir)
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached DOCX, parsing again")
}
readCachedSingleFileBook(metadataFile, extractionDir, "DOCX")?.let { cachedBook ->
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)

View file

@ -100,6 +100,7 @@ import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
private const val TAG_ANDROID_HIGHLIGHT_RENDER_DIAG = "AndroidHighlightRenderDiag"
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
"PageInfoReporter",
"ProgressReporter",
@ -386,6 +387,36 @@ private data class CustomMenuState(
val selectedColor: HighlightColor? = null
)
internal fun highlightsJsonForWebView(userHighlights: List<UserHighlight>): String {
val jsonArray = org.json.JSONArray()
userHighlights.forEach { highlight ->
val obj = JSONObject()
obj.put("id", highlight.id)
obj.put("cfi", highlight.cfi)
obj.put("text", highlight.text)
obj.put("cssClass", highlight.color.cssClass)
obj.put("colorId", highlight.color.id)
obj.put("chapterIndex", highlight.chapterIndex)
obj.put(
"locator",
JSONObject().apply {
highlight.locator.chapterIndex?.let { put("chapterIndex", it) }
highlight.locator.chapterId?.let { put("chapterId", it) }
highlight.locator.href?.let { put("href", it) }
highlight.locator.pageIndex?.let { put("pageIndex", it) }
highlight.locator.startOffset?.let { put("startOffset", it) }
highlight.locator.endOffset?.let { put("endOffset", it) }
highlight.locator.blockIndex?.let { put("blockIndex", it) }
highlight.locator.charOffset?.let { put("charOffset", it) }
highlight.locator.textQuote?.let { put("textQuote", it) }
highlight.locator.cfi?.let { put("cfi", it) }
}
)
jsonArray.put(obj)
}
return jsonArray.toString()
}
@Suppress("unused")
class AiJsBridge(
private val scope: CoroutineScope, private val onContentReady: suspend (String) -> Unit
@ -526,17 +557,7 @@ fun ChapterWebView(
)
}
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()
}
val highlightsJson = remember(userHighlights) { highlightsJsonForWebView(userHighlights) }
if (showExternalLinkDialog != null) {
val urlToShow = showExternalLinkDialog!!
@ -719,6 +740,11 @@ fun ChapterWebView(
)
}
message.startsWith("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG:") -> {
Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG)
.d("JS -> ${message.substringAfter("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG: ")}")
}
message.startsWith("ReaderFontDiagnosis") -> {
Timber.d(
"JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}"

View file

@ -61,6 +61,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -71,6 +72,7 @@ import androidx.core.text.HtmlCompat
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.ReaderLocator
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
@ -155,14 +157,20 @@ fun processAndAddHighlight(
newText: String,
newColor: HighlightColor,
chapterIndex: Int,
currentList: MutableList<UserHighlight>
currentList: MutableList<UserHighlight>,
locator: ReaderLocator = ReaderLocator.fromLegacy(
chapterIndex = chapterIndex,
cfi = newCfi,
textQuote = newText
)
): String {
return EpubAnnotationSerializer.processAndAddHighlight(
newCfi = newCfi,
newText = newText,
newColor = newColor,
chapterIndex = chapterIndex,
currentList = currentList
currentList = currentList,
locator = locator
)
}
@ -593,6 +601,7 @@ fun HighlightColorRow(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(28.dp)
.testTag("HighlightColor_${colorEnum.id}")
.clip(CircleShape) // 1. Clip shape for ripple
.background(colorEnum.color) // 2. Apply background
.clickable {

View file

@ -21,15 +21,17 @@ package com.aryan.reader.epubreader
import android.content.Context
import com.aryan.reader.R
import timber.log.Timber
import com.aryan.reader.applyBookReplacementsToHtmlDocument
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.LocatorConverter
import com.aryan.reader.shared.ReaderBookReplacementPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import timber.log.Timber
import java.io.File
data class ChapterLoadingResult(
@ -86,7 +88,9 @@ suspend fun loadChapterContent(
chunkTargetOverride: Int?,
isInitialCfiLoad: Boolean,
cfiToLoad: String?,
locatorConverter: LocatorConverter
locatorConverter: LocatorConverter,
bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(),
bookReplacementFileId: String? = null,
): ChapterLoadingResult = withContext(Dispatchers.IO) {
val chapter =
epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult(
@ -100,6 +104,11 @@ suspend fun loadChapterContent(
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
doc.select("script").remove()
applyBookReplacementsToHtmlDocument(
document = doc,
preferences = bookReplacementPreferences,
fileId = bookReplacementFileId,
)
val bodyNodes = doc.body().childNodes().toList()
val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
if (htmlChunks.isEmpty()) {

View file

@ -1,30 +1,6 @@
/*
* 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
*/
// EpubReaderControls.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.graphics.Bitmap
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
@ -38,14 +14,12 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -56,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
@ -72,6 +47,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowUpward
@ -82,6 +59,10 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
@ -121,9 +102,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@ -136,7 +115,6 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
import androidx.core.graphics.createBitmap
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
@ -145,15 +123,13 @@ import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.loadNativeVoice
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.readerSliderStepPage
import com.aryan.reader.shared.ui.ReaderMinimalSlider
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
import com.aryan.reader.tts.ReaderTtsOverlaySize
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import com.aryan.reader.tts.formatReaderTtsChunkLabel
import kotlin.math.roundToInt
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
@ -177,7 +153,8 @@ enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
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")
TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu"),
BOOK_REPLACEMENTS(R.string.menu_book_word_replacements, "Overflow Menu")
}
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
@ -286,6 +263,7 @@ internal enum class EpubOverflowMenuSection {
KEEP_SCREEN_ON,
VISUAL_OPTIONS,
AUTO_SCROLL,
BOOK_REPLACEMENTS,
TTS_SETTINGS,
FILE_INFO
}
@ -309,6 +287,7 @@ internal fun epubOverflowMenuSections(
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON)
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS)
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL)
if (!hiddenTools.contains(ReaderTool.BOOK_REPLACEMENTS.name)) add(EpubOverflowMenuSection.BOOK_REPLACEMENTS)
if (
!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) ||
!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
@ -381,11 +360,13 @@ fun EpubReaderTopBar(
volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean,
useNativeVerticalRenderer: Boolean,
onNavigateBack: () -> Unit,
isKeepScreenOn: Boolean,
onToggleKeepScreenOn: (Boolean) -> Unit,
onCloseSearch: () -> Unit,
onChangeRenderMode: (RenderMode) -> Unit,
onUseNativeVerticalRendererChange: (Boolean) -> Unit,
onToggleBookmark: () -> Unit,
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
@ -394,6 +375,7 @@ fun EpubReaderTopBar(
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit,
onOpenBookReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenBrightness: () -> Unit,
@ -701,14 +683,29 @@ fun EpubReaderTopBar(
)
if (showReadingModeExpanded) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
text = { Text(stringResource(R.string.menu_reading_mode_vertical_webview)) },
enabled = !isTtsActive,
onClick = {
onUseNativeVerticalRendererChange(false)
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
if (currentRenderMode == RenderMode.VERTICAL_SCROLL && !useNativeVerticalRenderer) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
})
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical_native)) },
enabled = !isTtsActive,
onClick = {
onUseNativeVerticalRendererChange(true)
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL && useNativeVerticalRenderer) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
@ -849,6 +846,22 @@ fun EpubReaderTopBar(
onStartAutoScroll()
})
}
EpubOverflowMenuSection.BOOK_REPLACEMENTS -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_book_word_replacements)) },
onClick = {
showMoreMenu = false
onOpenBookReplacements()
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.text_fields),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
EpubOverflowMenuSection.TTS_SETTINGS -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings)) },
@ -1158,26 +1171,18 @@ fun EpubReaderBottomBar(
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EpubReaderPageSlider(
isVisible: Boolean,
currentRenderMode: RenderMode,
totalPages: Int,
sliderCurrentPage: Float,
sliderStartPage: Int,
startPageThumbnail: Bitmap?,
paginator: IPaginator?,
chapters: List<EpubChapter>,
onScrub: (Float) -> Unit,
onJumpToPage: (Int) -> Unit,
modifier: Modifier = Modifier,
activeColor: Color = Color.Unspecified,
inactiveColor: Color = Color.Unspecified,
contentColor: Color = Color.Unspecified,
thumbnailSurfaceColor: Color = Color.Unspecified,
thumbnailContentColor: Color = Color.Unspecified
contentColor: Color = Color.Unspecified
) {
val effectiveActiveColor = if (activeColor == Color.Unspecified) {
MaterialTheme.colorScheme.primary
@ -1194,16 +1199,8 @@ fun EpubReaderPageSlider(
} else {
contentColor
}
val effectiveThumbnailSurfaceColor = if (thumbnailSurfaceColor == Color.Unspecified) {
MaterialTheme.colorScheme.surfaceVariant
} else {
thumbnailSurfaceColor
}
val effectiveThumbnailContentColor = if (thumbnailContentColor == Color.Unspecified) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
thumbnailContentColor
}
val maxPage = totalPages.coerceAtLeast(1)
val currentPage = sliderCurrentPage.roundToInt().coerceIn(1, maxPage)
AnimatedVisibility(
visible = isVisible,
@ -1211,128 +1208,73 @@ fun EpubReaderPageSlider(
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(Modifier.height(72.dp))
Box(
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 32.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
BoxWithConstraints(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Slider(
value = sliderCurrentPage,
onValueChange = onScrub,
valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)),
steps = if (totalPages > 2) totalPages - 2 else 0,
modifier = Modifier.fillMaxWidth(),
thumb = {
Surface(
modifier = Modifier.size(20.dp),
shape = CircleShape,
color = effectiveActiveColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {}
},
track = { sliderState ->
val trackHeight = 2.dp
val trackShape = RoundedCornerShape(trackHeight)
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
val fraction = if (range == 0f) 0f else {
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(trackHeight)
.background(
color = effectiveInactiveColor,
shape = trackShape
)
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.background(
color = effectiveActiveColor,
shape = trackShape
)
)
}
}
)
// Thumbnail Indicator
val startPageOffsetFraction = if (totalPages > 1) {
(sliderStartPage - 1).toFloat() / (totalPages - 1)
} else {
0f
}
val thumbWidth = 20.dp
val trackWidth = maxWidth - thumbWidth
val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
val thumbnailModifier = Modifier
.graphicsLayer { clip = false }
.align(Alignment.TopStart)
.offset(
x = startPagePixelPosition - (45.dp / 2),
y = (-72).dp
IconButton(
onClick = {
onJumpToPage(
readerSliderStepPage(
currentPage = currentPage,
delta = -1,
minPage = 1,
maxPage = maxPage
)
)
},
enabled = currentPage > 1,
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = stringResource(R.string.desktop_previous_page),
tint = effectiveContentColor.copy(alpha = if (currentPage > 1) 0.9f else 0.32f)
)
}
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
startPageThumbnail?.let { thumbnail ->
ThumbnailWithIndicator(
modifier = thumbnailModifier,
borderColor = effectiveActiveColor,
onClick = { onJumpToPage(sliderStartPage) }
) {
Image(
bitmap = thumbnail.asImageBitmap(),
contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
}
}
} else {
val startPageChapterIndex = remember(sliderStartPage, paginator) {
(paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1)
}
val startPageChapterTitle = remember(startPageChapterIndex) {
startPageChapterIndex?.let { chapters.getOrNull(it)?.title }
}
ThumbnailWithIndicator(
modifier = thumbnailModifier,
borderColor = effectiveActiveColor,
onClick = { onJumpToPage(sliderStartPage) }
) {
PaginatedThumbnailContent(
pageNumber = sliderStartPage,
chapterTitle = startPageChapterTitle,
surfaceColor = effectiveThumbnailSurfaceColor,
contentColor = effectiveThumbnailContentColor
)
}
}
}
ReaderMinimalSlider(
value = sliderCurrentPage.coerceIn(1f, maxPage.toFloat()),
onValueChange = onScrub,
valueRange = 1f..maxPage.toFloat(),
enabled = maxPage > 1,
activeColor = effectiveActiveColor,
inactiveColor = effectiveInactiveColor,
thumbColor = effectiveActiveColor,
markerValue = sliderStartPage.toFloat(),
markerColor = effectiveActiveColor,
modifier = Modifier
.weight(1f)
.height(32.dp)
)
Text(
text = "${sliderCurrentPage.roundToInt()} / $totalPages",
style = MaterialTheme.typography.bodyLarge,
color = effectiveContentColor,
fontSize = 18.sp
IconButton(
onClick = {
onJumpToPage(
readerSliderStepPage(
currentPage = currentPage,
delta = 1,
minPage = 1,
maxPage = maxPage
)
)
},
enabled = currentPage < maxPage,
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = stringResource(R.string.desktop_next_page),
tint = effectiveContentColor.copy(alpha = if (currentPage < maxPage) 0.9f else 0.32f)
)
}
}
@ -1375,109 +1317,6 @@ fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
}
}
@Composable
internal fun ThumbnailWithIndicator(
modifier: Modifier = Modifier,
borderColor: Color = Color.Unspecified,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
val effectiveBorderColor = if (borderColor == Color.Unspecified) {
MaterialTheme.colorScheme.primary
} else {
borderColor
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier
.width(45.dp)
.height(64.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(4.dp),
border = BorderStroke(2.dp, effectiveBorderColor)
) {
content()
}
Box(
modifier = Modifier
.offset(y = (-4).dp)
.size(8.dp)
.rotate(45f)
.background(effectiveBorderColor)
)
}
}
@Composable
private fun PaginatedThumbnailContent(
pageNumber: Int,
chapterTitle: String?,
surfaceColor: Color = Color.Unspecified,
contentColor: Color = Color.Unspecified
) {
val effectiveSurfaceColor = if (surfaceColor == Color.Unspecified) {
MaterialTheme.colorScheme.surfaceVariant
} else {
surfaceColor
}
val effectiveContentColor = if (contentColor == Color.Unspecified) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
contentColor
}
Surface(
modifier = Modifier.fillMaxSize(),
color = effectiveSurfaceColor,
contentColor = effectiveContentColor
) {
Column(
modifier = Modifier.padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (chapterTitle != null) {
Text(
text = chapterTitle,
style = MaterialTheme.typography.labelSmall,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
lineHeight = 10.sp
)
Spacer(modifier = Modifier.height(4.dp))
}
Text(
text = "$pageNumber",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
}
}
}
suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
return withContext(Dispatchers.Main) {
if (webView.width <= 0 || webView.height <= 0) return@withContext null
try {
val thumbnailWidth = 180
val thumbnailHeight = 256
val bitmap = createBitmap(thumbnailWidth, thumbnailHeight)
val canvas = Canvas(bitmap)
val scale = thumbnailWidth.toFloat() / webView.width.toFloat()
canvas.scale(scale, scale)
canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat())
webView.draw(canvas)
bitmap
} catch (e: Exception) {
Timber.e(e, "Failed to capture webview content")
null
}
}
}
@Composable
fun SpeedDropdown(
label: String,
@ -2204,6 +2043,7 @@ private fun ToolPreviewIcon(tool: ReaderTool, isSliderActive: Boolean = false) {
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.BOOK_REPLACEMENTS -> Icon(painterResource(id = R.drawable.text_fields), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, 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))
@ -2260,8 +2100,8 @@ fun TtsOverlayControls(
ttsController: com.aryan.reader.tts.TtsController,
ttsState: TtsState,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
overlaySize: ReaderTtsOverlaySize,
onOverlaySizeChange: (ReaderTtsOverlaySize) -> Unit,
onLocateCurrentChunk: () -> Unit,
onOpenTtsSettings: () -> Unit,
onClose: () -> Unit,
@ -2301,11 +2141,17 @@ fun TtsOverlayControls(
}
}
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
"Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
} else {
null
}
formatReaderTtsChunkLabel(ttsState.currentChunkIndex, ttsState.totalChunks)
}
val miniBarTitle = ttsState.bookTitle
?.takeIf { it.isNotBlank() }
?: stringResource(R.string.action_read_aloud)
val miniBarSubtitle = remember(chapterLabel, chunkLabel, progressPercent, miniBarTitle) {
listOfNotNull(
chunkLabel,
progressPercent?.let { "$it%" },
chapterLabel?.takeIf { it != miniBarTitle }
).joinToString(" - ")
}
val canSkipPreviousChunk = !ttsState.isLoading &&
ttsState.currentChunkIndex > 0 &&
@ -2330,24 +2176,40 @@ fun TtsOverlayControls(
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = modifier.widthIn(max = 400.dp).animateContentSize()
modifier = modifier
.widthIn(max = if (overlaySize == ReaderTtsOverlaySize.MEDIUM) 560.dp else 400.dp)
.animateContentSize()
) {
AnimatedContent(
targetState = isCollapsed,
targetState = overlaySize,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "TtsOverlayUnified"
) { collapsed ->
if (collapsed) {
) { size ->
if (size == ReaderTtsOverlaySize.SMALL) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(
onClick = { onCollapseChange(false) },
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(
Icons.Default.KeyboardArrowUp,
stringResource(R.string.content_desc_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.KeyboardArrowLeft,
stringResource(R.string.content_desc_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
@ -2371,6 +2233,114 @@ fun TtsOverlayControls(
)
}
}
} else if (size == ReaderTtsOverlaySize.MEDIUM) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onLocateCurrentChunk)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = miniBarTitle,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (miniBarSubtitle.isNotBlank()) {
Text(
text = miniBarSubtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(Modifier.width(4.dp))
IconButton(
enabled = canSkipPreviousChunk,
onClick = { ttsController.skipToPreviousChunk() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.SkipPrevious,
contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
modifier = Modifier.size(24.dp)
)
}
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
modifier = Modifier.size(44.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
stringResource(R.string.content_desc_play_pause),
modifier = Modifier.size(22.dp)
)
}
if (ttsState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
strokeWidth = 2.dp
)
}
}
IconButton(
enabled = canSkipNextChunk,
onClick = { ttsController.skipToNextChunk() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
modifier = Modifier.size(24.dp)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
IconButton(
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) },
modifier = Modifier.size(34.dp)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = stringResource(R.string.content_desc_expand),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) },
modifier = Modifier.size(34.dp)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = stringResource(R.string.content_desc_collapse),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@ -2378,7 +2348,10 @@ fun TtsOverlayControls(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(8.dp)
@ -2428,6 +2401,8 @@ fun TtsOverlayControls(
}
}
Spacer(Modifier.width(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
Icon(
@ -2437,8 +2412,27 @@ fun TtsOverlayControls(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.ChevronRight, stringResource(R.string.content_desc_collapse), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
IconButton(
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.KeyboardArrowDown,
stringResource(R.string.content_desc_collapse),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.KeyboardArrowRight,
stringResource(R.string.content_desc_collapse),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, stringResource(R.string.content_desc_stop_tts), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))

View file

@ -53,15 +53,64 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import java.io.File
import kotlin.math.max
import kotlin.math.min
private const val EPUB_SEARCH_WINDOW_CHARS = 32_768
private const val EPUB_SEARCH_SNIPPET_RADIUS = 35
private const val EPUB_SEARCH_MAX_OVERLAP_CHARS = 4_096
private val epubSearchSkippedTags = setOf("script", "style", "noscript")
private val epubSearchBlockBoundaryTags = setOf(
"address",
"article",
"aside",
"blockquote",
"br",
"caption",
"dd",
"div",
"dl",
"dt",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"li",
"main",
"nav",
"ol",
"p",
"pre",
"section",
"table",
"td",
"th",
"tr",
"ul"
)
/**
* Creates the search implementation for EPUB chapters.
*/
fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResult> = { query ->
withContext(Dispatchers.Default) {
val searchQuery = query.trim()
if (searchQuery.isBlank()) {
return@withContext emptyList()
}
val results = mutableListOf<SearchResult>()
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
try {
@ -69,54 +118,208 @@ fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResul
if (!htmlFile.exists()) return@forEachIndexed
val doc = Jsoup.parse(htmlFile, "UTF-8")
val bodyChildren = doc.body().children().toList()
val chunks = bodyChildren.chunked(20)
doc.select("script, style, noscript").remove()
val bodyNodes = doc.body().childNodes().toList()
val chunks = bodyNodes.chunked(20)
var occurrenceIndexInChapter = 0
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
val content = Jsoup.parse(chunkHtml).text()
var lastIndex = -1
while (true) {
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
if (isWordStart) {
val snippetStart = max(0, lastIndex - 35)
val snippetEnd = min(content.length, lastIndex + query.length + 35)
val rawSnippet = content.substring(snippetStart, snippetEnd)
val annotatedSnippet = buildAnnotatedString {
append(rawSnippet)
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
val highlightEnd = highlightStart + query.length
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightEnd
)
}
results.add(
SearchResult(
locationInSource = chapterIndex,
locationTitle = chapter.title,
snippet = annotatedSnippet,
query = query,
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
chunkIndex = chunkIndex
)
)
}
}
chunks.forEachIndexed { chunkIndex, chunkNodes ->
occurrenceIndexInChapter = appendSearchResultsFromNodes(
nodes = chunkNodes,
query = searchQuery,
chapterIndex = chapterIndex,
chapterTitle = chapter.title,
chunkIndex = chunkIndex,
occurrenceIndexInChapter = occurrenceIndexInChapter,
results = results
)
}
} catch (e: Exception) {
Timber.e("Failed to search in chapter $chapterIndex", e)
Timber.e(e, "Failed to search in chapter $chapterIndex")
} catch (e: OutOfMemoryError) {
Timber.e(e, "Skipping search in chapter $chapterIndex after running out of memory")
}
}
results
}
}
private fun appendSearchResultsFromNodes(
nodes: List<Node>,
query: String,
chapterIndex: Int,
chapterTitle: String,
chunkIndex: Int,
occurrenceIndexInChapter: Int,
results: MutableList<SearchResult>
): Int {
val searchWindow = EpubSearchWindow(
query = query,
chapterIndex = chapterIndex,
chapterTitle = chapterTitle,
chunkIndex = chunkIndex,
initialOccurrenceIndex = occurrenceIndexInChapter,
results = results
)
nodes.forEach { node ->
searchWindow.visit(node)
}
searchWindow.finish()
return searchWindow.occurrenceIndex
}
private class EpubSearchWindow(
private val query: String,
private val chapterIndex: Int,
private val chapterTitle: String,
private val chunkIndex: Int,
initialOccurrenceIndex: Int,
private val results: MutableList<SearchResult>
) {
private val buffer = StringBuilder()
private val overlapChars = (query.length + EPUB_SEARCH_SNIPPET_RADIUS)
.coerceIn(EPUB_SEARCH_SNIPPET_RADIUS * 2, EPUB_SEARCH_MAX_OVERLAP_CHARS)
private var lastAppendedWasWhitespace = true
private var previousCharBeforeBuffer: Char? = null
var occurrenceIndex: Int = initialOccurrenceIndex
private set
fun visit(node: Node) {
when (node) {
is TextNode -> appendNormalizedText(node.wholeText)
is Element -> {
val tagName = node.tagName().lowercase()
if (tagName in epubSearchSkippedTags) return
if (tagName == "br") {
appendNormalizedWhitespace()
return
}
node.childNodes().forEach(::visit)
if (tagName in epubSearchBlockBoundaryTags) {
appendNormalizedWhitespace()
}
}
else -> node.childNodes().forEach(::visit)
}
}
fun finish() {
scanBuffer(buffer.length)
buffer.clear()
previousCharBeforeBuffer = null
}
private fun appendNormalizedText(text: String) {
text.forEach { char ->
if (char.isWhitespace()) {
appendNormalizedWhitespace()
} else {
buffer.append(char)
lastAppendedWasWhitespace = false
trimScannedPrefixIfNeeded()
}
}
}
private fun appendNormalizedWhitespace() {
if (buffer.isEmpty() || lastAppendedWasWhitespace) {
lastAppendedWasWhitespace = true
return
}
buffer.append(' ')
lastAppendedWasWhitespace = true
trimScannedPrefixIfNeeded()
}
private fun trimScannedPrefixIfNeeded() {
if (buffer.length < EPUB_SEARCH_WINDOW_CHARS) return
val scanEndExclusive = (buffer.length - overlapChars).coerceAtLeast(0)
if (scanEndExclusive <= 0) return
scanBuffer(scanEndExclusive)
previousCharBeforeBuffer = buffer[scanEndExclusive - 1]
buffer.delete(0, scanEndExclusive)
}
private fun scanBuffer(scanEndExclusive: Int) {
var searchFrom = 0
while (searchFrom < scanEndExclusive) {
val matchStart = buffer.indexOfIgnoreCase(query, searchFrom, scanEndExclusive)
if (matchStart == -1) break
if (isWordStart(matchStart)) {
addSearchResult(matchStart)
}
searchFrom = matchStart + 1
}
}
private fun isWordStart(matchStart: Int): Boolean {
val previousChar = if (matchStart > 0) {
buffer[matchStart - 1]
} else {
previousCharBeforeBuffer
}
return previousChar == null || !previousChar.isLetterOrDigit()
}
private fun addSearchResult(matchStart: Int) {
val snippetStart = max(0, matchStart - EPUB_SEARCH_SNIPPET_RADIUS)
val snippetEnd = min(buffer.length, matchStart + query.length + EPUB_SEARCH_SNIPPET_RADIUS)
val rawSnippet = buffer.substring(snippetStart, snippetEnd)
val highlightStart = matchStart - snippetStart
val highlightEnd = highlightStart + query.length
val annotatedSnippet = buildAnnotatedString {
append(rawSnippet)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightEnd
)
}
results.add(
SearchResult(
locationInSource = chapterIndex,
locationTitle = chapterTitle,
snippet = annotatedSnippet,
query = query,
occurrenceIndexInLocation = occurrenceIndex,
chunkIndex = chunkIndex
)
)
occurrenceIndex++
}
}
private fun CharSequence.indexOfIgnoreCase(
query: String,
startIndex: Int,
matchStartLimitExclusive: Int
): Int {
if (query.isEmpty()) return -1
val lastStart = min(length - query.length, matchStartLimitExclusive - 1)
if (lastStart < startIndex) return -1
var index = startIndex.coerceAtLeast(0)
while (index <= lastStart) {
var queryIndex = 0
while (
queryIndex < query.length &&
this[index + queryIndex].equals(query[queryIndex], ignoreCase = true)
) {
queryIndex++
}
if (queryIndex == query.length) return index
index++
}
return -1
}
/**
* Handles the navigation to a specific search result.
*/

View file

@ -96,6 +96,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -106,6 +108,7 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.supportedFontMimeTypes
import java.io.File
import kotlin.math.roundToInt
@ -130,6 +133,7 @@ private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
private const val NATIVE_VERTICAL_RENDERER_KEY = "reader_native_vertical_renderer"
const val DEFAULT_FONT_SIZE_VAL = 1.0f
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
@ -295,6 +299,16 @@ fun loadPullToTurn(context: Context): Boolean {
return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true)
}
fun saveNativeVerticalRenderer(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(NATIVE_VERTICAL_RENDERER_KEY, enabled) }
}
fun loadNativeVerticalRenderer(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(NATIVE_VERTICAL_RENDERER_KEY, false)
}
private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier"
fun savePullToTurnMultiplier(context: Context, multiplier: Float) {
@ -603,6 +617,7 @@ fun ReaderTextFormatPanel(
)
// Font Button
val fontSelectorDescription = stringResource(R.string.content_desc_select_font_family)
Surface(
onClick = onFontOptionClick,
shape = RoundedCornerShape(12.dp),
@ -610,6 +625,9 @@ fun ReaderTextFormatPanel(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.semantics {
contentDescription = fontSelectorDescription
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -769,12 +787,12 @@ fun FontSelectionSheetContent(
currentCustomFontPath: String?,
onFontSelected: (ReaderFont, String?) -> Unit,
customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit,
onImportFonts: (List<Uri>) -> Unit,
onDismiss: () -> Unit
) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { onImportFont(it) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris.isNotEmpty()) onImportFonts(uris)
}
Column(modifier = Modifier.fillMaxWidth()) {
@ -817,7 +835,7 @@ fun FontSelectionSheetContent(
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Button(
onClick = { launcher.launch(arrayOf("font/ttf", "font/otf", "application/x-font-ttf")) },
onClick = { launcher.launch(supportedFontMimeTypes()) },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Add, contentDescription = null)

View file

@ -0,0 +1,14 @@
package com.aryan.reader.epubreader
import com.aryan.reader.shared.PageInfoMode
internal fun shouldShowEpubPageInfoBar(
pageInfoMode: PageInfoMode,
showReaderChrome: Boolean
): Boolean {
return when (pageInfoMode) {
PageInfoMode.DEFAULT -> true
PageInfoMode.SYNC -> showReaderChrome
PageInfoMode.HIDDEN -> false
}
}

View file

@ -116,7 +116,7 @@ class OpdsRepository(context: Context) : SharedOpdsRepository {
if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
val realm = extractParam(wwwAuth, "realm") ?: ""
val nonce = extractParam(wwwAuth, "nonce") ?: ""
val qop = extractParam(wwwAuth, "qop")
val qop = selectAuthQop(extractParam(wwwAuth, "qop"))
val opaque = extractParam(wwwAuth, "opaque")
cnonceCount++
@ -162,6 +162,13 @@ class OpdsRepository(context: Context) : SharedOpdsRepository {
return match?.groupValues?.get(1)
}
private fun selectAuthQop(value: String?): String? {
return value
?.split(',')
?.map { it.trim().trim('"') }
?.firstOrNull { it.equals("auth", ignoreCase = true) }
}
private fun md5(input: String): String {
val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }

View file

@ -2,6 +2,7 @@ package com.aryan.reader.paginatedreader
import android.graphics.BitmapFactory
import androidx.compose.ui.text.font.FontFamily
import com.aryan.reader.epub.safeFileInRoot
import java.io.File
import java.net.URLDecoder
import java.nio.file.Paths
@ -16,12 +17,14 @@ object AndroidHtmlResourceResolver : HtmlResourceResolver {
}
val parentPath = File(chapterAbsPath).parent ?: ""
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
val fromRelativeFile = File(extractionBasePath, relativePath)
return try {
val extractionRoot = File(extractionBasePath)
val fromRelativeFile = safeFileInRoot(extractionRoot, relativePath)
val fromRootFile = safeFileInRoot(extractionRoot, decodedSrc)
when {
fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath
File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath
fromRelativeFile?.exists() == true -> fromRelativeFile.absolutePath
fromRootFile?.exists() == true -> fromRootFile.absolutePath
else -> null
}
} catch (_: Exception) {

View file

@ -37,8 +37,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import com.aryan.reader.SearchResult
import com.aryan.reader.applyBookReplacementsToHtmlDocument
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.epub.plainTextCharacterCount
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookProcessingInput
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
@ -50,14 +52,20 @@ import com.aryan.reader.paginatedreader.data.PageIndexEntry
import com.aryan.reader.paginatedreader.data.ProcessedBook
import com.aryan.reader.paginatedreader.data.ProcessedChapter
import com.aryan.reader.paginatedreader.data.SerializableEpubChapter
import com.aryan.reader.shared.ReaderBookReplacementPreferences
import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson
import com.aryan.reader.tts.PageCharacterRange
import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.cancel
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@ -74,6 +82,8 @@ import java.net.URI
import java.net.URLDecoder
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
private const val PRIORITY_HIGHEST = 0
private const val PRIORITY_HIGH = 1
@ -134,7 +144,7 @@ private data class PageNavigationEntry(
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Stable
class BookPaginator(
private val coroutineScope: CoroutineScope,
coroutineScope: CoroutineScope,
private val chapters: List<EpubChapter>,
private val textMeasurer: TextMeasurer,
private val constraints: Constraints,
@ -157,7 +167,9 @@ class BookPaginator(
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float,
private val imageSizeMultiplier: Float,
private val verticalMarginMultiplier: Float
private val verticalMarginMultiplier: Float,
private val bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(),
private val bookReplacementFileId: String? = null
) : IPaginator {
override var totalPageCount by mutableIntStateOf(0)
private set
@ -200,8 +212,27 @@ class BookPaginator(
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
private val chapterPaginationLocks = ConcurrentHashMap<Int, Mutex>()
private val chapterBlockLocks = ConcurrentHashMap<Int, Mutex>()
private val navigationCallbacks = ConcurrentHashMap<Int, MutableList<(List<Page>) -> Unit>>()
private var paginationWorker: Job? = null
private val paginatorJob = SupervisorJob(coroutineScope.coroutineContext[Job])
private val paginatorScope = CoroutineScope(coroutineScope.coroutineContext + paginatorJob)
@Volatile
private var disposed = false
override fun dispose() {
if (disposed) return
disposed = true
paginationQueue.clear()
navigationCallbacks.clear()
chaptersBeingProcessed.clear()
paginationWorker?.cancel()
paginatorJob.cancel(CancellationException("BookPaginator disposed"))
isLoading = false
Timber.i("BookPaginator disposed for book=$bookId configHash=$currentConfigHash")
}
private fun isDisposed(): Boolean = disposed || !paginatorJob.isActive
internal fun getCharactersScrolledInChapter(chapterIndex: Int, pageInChapter: Int): Long {
val cumulativeCharsList = chapterCumulativeChars[chapterIndex]
@ -224,7 +255,7 @@ class BookPaginator(
Timber.e("Paginator received UNBOUNDED HEIGHT. Pagination will fail.")
} else {
Timber.i("Paginator initializing with constraints: $constraints")
coroutineScope.launch {
paginatorScope.launch {
isLoading = true
Timber.d("Initialization started.")
@ -236,22 +267,46 @@ class BookPaginator(
return@launch
}
// 1. Book processing check (Keep existing logic)
// 1. Generate config hash before touching semantic cache; processed chapters are style-sensitive.
coroutineContext.ensureActive()
currentConfigHash = generateConfigurationHash()
coroutineContext.ensureActive()
// 2. Book processing check (Keep existing logic)
val bookRecord = bookCacheDao.getProcessedBook(bookId)
var shouldEnqueueBookProcessing = false
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
Timber.i("Book cache is new or stale. Creating initial record.")
bookCacheDao.deleteEntireBookCache(bookId)
val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0
bookCacheDao.insertProcessedBook(initialBook)
enqueueBookProcessingWork()
shouldEnqueueBookProcessing = true
} else if (bookCacheDao.getProcessedChapter(
bookId,
initialChapterToPaginate.coerceIn(0, chapters.lastIndex),
currentConfigHash
) == null
) {
Timber.i("Semantic chapter cache is missing for current style config. Enqueuing config-aware processing.")
shouldEnqueueBookProcessing = true
}
// 2. GENERATE CONFIG HASH
currentConfigHash = generateConfigurationHash()
coroutineContext.ensureActive()
if (isDisposed()) return@launch
if (shouldEnqueueBookProcessing) {
enqueueBookProcessingWork()
} else {
BookProcessingWorker.cancelForBook(context, bookId)
}
// 3. TRY LOAD EXACT COUNTS FROM DB
coroutineContext.ensureActive()
if (isDisposed()) return@launch
val cachedConfig = bookCacheDao.getConfigurationCache(bookId, currentConfigHash)
coroutineContext.ensureActive()
if (isDisposed()) return@launch
if (cachedConfig != null) {
Timber.i("Configuration Cache HIT. Using saved page counts.")
applyAccuratePageCounts(cachedConfig.chapterPageCounts)
@ -308,15 +363,7 @@ class BookPaginator(
}
private fun getAllTextBlocks(blocks: List<ContentBlock>): List<TextContentBlock> {
return blocks.flatMap { block ->
when (block) {
is WrappingContentBlock -> getAllTextBlocks(block.paragraphsToWrap)
is FlexContainerBlock -> getAllTextBlocks(block.children)
is TableBlock -> block.rows.flatten().flatMap { getAllTextBlocks(it.content) }
is TextContentBlock -> listOf(block)
else -> emptyList()
}
}
return flattenTextContentBlocksForNavigation(blocks)
}
private fun generateConfigurationHash(): Int {
@ -326,10 +373,14 @@ class BookPaginator(
append("-fs:${textStyle.fontSize.value}")
append("-lh:${textStyle.lineHeight.value}")
append("-ff:${textStyle.fontFamily}")
append("-style:${textStyle.hashCode()}")
append("-density:${density.density}")
append("-fontScale:${density.fontScale}")
append("-ta:$userTextAlign")
append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier")
append("-vm:$verticalMarginMultiplier")
append("-book-replacements:${bookReplacementPreferences.signatureForFile(bookReplacementFileId)}")
append("-proc:$LATEST_PROCESSING_VERSION")
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
append("-ua:${userAgentStylesheet.hashCode()}")
@ -413,7 +464,7 @@ class BookPaginator(
append('|')
append(chapter.htmlContent.hashCode())
append('|')
append(chapter.plainTextContent.length)
append(chapter.plainTextCharacterCount())
append('|')
append(chapter.plainTextContent.hashCode())
append('|')
@ -426,13 +477,24 @@ class BookPaginator(
}
private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List<Page>? {
val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: return null
val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: run {
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"page_cache_lookup result=miss chapter=$chapterIndex configHash=$currentConfigHash"
)
return null
}
val expectedContentVersion = chapterContentVersion(chapter)
val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION &&
cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION &&
cachedPages.contentVersion == expectedContentVersion
if (!isCompatible) {
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"page_cache_lookup result=stale chapter=$chapterIndex " +
"cachedProcessing=${cachedPages.processingVersion} expectedProcessing=$LATEST_PROCESSING_VERSION " +
"cachedPageCache=${cachedPages.pageCacheVersion} expectedPageCache=$LATEST_PAGE_CACHE_VERSION " +
"cachedContent=${cachedPages.contentVersion} expectedContent=$expectedContentVersion"
)
Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.")
return null
}
@ -446,6 +508,10 @@ class BookPaginator(
applyPageRuntimeIndexes(chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size)
pageCache.put(chapterIndex, pages)
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"page_cache_lookup result=hit chapter=$chapterIndex configHash=$currentConfigHash " +
pages.readerPagesLinkDiagSummary()
)
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
pages
}
@ -456,8 +522,14 @@ class BookPaginator(
}
private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List<Page>) {
coroutineScope.launch(Dispatchers.IO) {
if (isDisposed()) return
paginatorScope.launch(Dispatchers.IO) {
if (isDisposed()) return@launch
try {
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"page_cache_save chapter=$chapterIndex configHash=$currentConfigHash " +
pages.readerPagesLinkDiagSummary()
)
val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages)
val cacheEntry = PageCacheEntry(
bookId = bookId,
@ -578,16 +650,19 @@ class BookPaginator(
private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) {
withContext(Dispatchers.Main) {
if (isDisposed()) return@withContext
if (chapterPageCounts[chapterIndex] != actualPageCount) {
updatePageCounts(chapterIndex, actualPageCount)
} else if (finalizedChapterCounts.add(chapterIndex)) {
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
}
generation++
}
}
private suspend fun ensureChapterPaginated(chapterIndex: Int): List<Page>? {
coroutineContext.ensureActive()
if (isDisposed()) return null
if (chapterIndex !in chapters.indices) {
Timber.w("ensureChapterPaginated: Ignoring invalid chapter index $chapterIndex.")
return null
@ -612,6 +687,29 @@ class BookPaginator(
}
}
private suspend fun getCachedBlocksForChapter(chapter: EpubChapter, chapterIndex: Int): List<ContentBlock> {
blockCache[chapterIndex]?.let {
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"content_l2_cache_hit chapter=$chapterIndex " + it.readerContentLinkDiagSummary()
)
return it
}
val lock = chapterBlockLocks.computeIfAbsent(chapterIndex) { Mutex() }
return lock.withLock {
blockCache[chapterIndex]?.also {
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"content_l2_cache_hit_after_wait chapter=$chapterIndex " + it.readerContentLinkDiagSummary()
)
} ?: run {
Timber.d("getCachedBlocksForChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
getBlocksForChapter(chapter, chapterIndex).also { blocks ->
blockCache.put(chapterIndex, blocks)
}
}
}
}
private suspend fun ensureStableStartPageForChapter(chapterIndex: Int): Int? {
Timber.tag(TAG_STABLE_PAGE_NAV).d(
"stable_start request chapter=$chapterIndex countsAccurate=$pageCountsAreAccurate finalized=${chapterIndex in finalizedChapterCounts}"
@ -672,7 +770,7 @@ class BookPaginator(
ordinalInChapter: Int
): Pair<Int, Locator>? = withContext(Dispatchers.IO) {
val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
val imageBlocks = getAllBlocks(getBlocksForChapter(chapter, chapterIndex))
val imageBlocks = getAllBlocks(getCachedBlocksForChapter(chapter, chapterIndex))
.filterIsInstance<ImageBlock>()
if (imageBlocks.isEmpty()) return@withContext null
@ -756,6 +854,7 @@ class BookPaginator(
}
private fun enqueueBookProcessingWork() {
if (isDisposed()) return
val serializableChapters = chapters.map {
SerializableEpubChapter(
htmlContent = it.htmlContent,
@ -773,9 +872,15 @@ class BookPaginator(
density = density.density,
constraintsMaxWidth = constraints.maxWidth,
constraintsMaxHeight = constraints.maxHeight,
fontFaces = this.allFontFaces
fontFaces = this.allFontFaces,
styleConfigHash = currentConfigHash,
bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode(
bookReplacementPreferences.scopedToFile(bookReplacementFileId),
),
bookReplacementFileId = bookReplacementFileId.orEmpty()
)
if (isDisposed()) return
BookProcessingWorker.enqueue(
context = context,
bookId = bookId,
@ -802,10 +907,14 @@ class BookPaginator(
adaptThemeColors = false
)
bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter ->
bookCacheDao.getProcessedChapter(bookId, chapterIndex, currentConfigHash)?.let { cachedChapter ->
if (cachedChapter.contentBlocksProto.isNotEmpty()) {
try {
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"semantic_cache_hit chapter=$chapterIndex configHash=$currentConfigHash " +
semanticBlocks.readerSemanticLinkDiagSummary()
)
val isCacheEmpty = semanticBlocks.isEmpty()
val isLazyChapter = chapter.htmlContent.isEmpty()
@ -821,7 +930,12 @@ class BookPaginator(
if (!shouldIgnoreCache) {
Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.")
return styler.style(semanticBlocks)
val styledBlocks = styler.style(semanticBlocks)
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"content_from_semantic_cache chapter=$chapterIndex configHash=$currentConfigHash " +
styledBlocks.readerContentLinkDiagSummary()
)
return styledBlocks
}
} catch (e: Exception) {
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.")
@ -849,6 +963,10 @@ class BookPaginator(
}
val document = Jsoup.parse(htmlToParse, chapter.absPath)
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"html_parse_input chapter=$chapterIndex htmlChars=${htmlToParse.length} " +
document.readerHtmlLinkDiagSummary()
)
val mathElements = document.select("math")
val svgResults = mutableMapOf<String, String>()
@ -865,6 +983,11 @@ class BookPaginator(
element.replaceWith(placeholder)
}
}
applyBookReplacementsToHtmlDocument(
document = document,
preferences = bookReplacementPreferences,
fileId = bookReplacementFileId,
)
val processedHtml = document.outerHtml()
var parsingCssRules = OptimizedCssRules()
@ -887,11 +1010,15 @@ class BookPaginator(
mathSvgCache = svgResults,
adaptThemeColors = false
)
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"semantic_parse_result chapter=$chapterIndex " +
semanticBlocks.readerSemanticLinkDiagSummary()
)
coroutineScope.launch(Dispatchers.IO) {
if (!isDisposed()) paginatorScope.launch(Dispatchers.IO) {
try {
val protoBytes = proto.encodeToByteArray(semanticBlocks)
val newCacheEntry = ProcessedChapter(bookId, chapterIndex, protoBytes, chapterPageCounts[chapterIndex] ?: 0)
val newCacheEntry = ProcessedChapter(bookId, chapterIndex, protoBytes, chapterPageCounts[chapterIndex] ?: 0, currentConfigHash)
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
Timber.i("Successfully cached SEMANTIC content for chapter $chapterIndex.")
} catch (e: Exception) {
@ -899,15 +1026,27 @@ class BookPaginator(
}
}
return styler.style(semanticBlocks)
val styledBlocks = styler.style(semanticBlocks)
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"content_parse_result chapter=$chapterIndex " +
styledBlocks.readerContentLinkDiagSummary()
)
return styledBlocks
}
private fun startPaginationWorker(): Job = coroutineScope.launch(Dispatchers.IO) {
internal suspend fun getFlowBlocksForChapter(chapterIndex: Int): List<ContentBlock>? = withContext(Dispatchers.IO) {
coroutineContext.ensureActive()
if (isDisposed()) return@withContext null
val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
getCachedBlocksForChapter(chapter, chapterIndex)
}
private fun startPaginationWorker(): Job = paginatorScope.launch(Dispatchers.IO) {
Timber.i("Pagination worker started.")
while (isActive) {
var request: PaginationRequest? = null
try {
request = paginationQueue.take()
request = paginationQueue.poll(250, TimeUnit.MILLISECONDS) ?: continue
val chapterIndex = request.chapterIndex
Timber.d("Worker: Took chapter $chapterIndex from queue with priority ${request.priority}.")
@ -933,6 +1072,9 @@ class BookPaginator(
} else {
Timber.e("Worker: Pagination for chapter $chapterIndex resulted in null.")
}
} catch (e: CancellationException) {
Timber.i("Pagination worker cancelled. Shutting down.")
throw e
} catch (_: InterruptedException) {
Timber.i("Pagination worker interrupted. Shutting down.")
Thread.currentThread().interrupt()
@ -968,7 +1110,7 @@ class BookPaginator(
"page_count_noop chapter=$chapterIndex count=$actualPageCount currentUserChapter=${currentUserChapterIndex.value}"
)
if (!pageCountsAreAccurate && finalizedChapterCounts.add(chapterIndex)) {
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
paginatorScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
}
return
}
@ -1000,7 +1142,7 @@ class BookPaginator(
if (!pageCountsAreAccurate) {
if (finalizedChapterCounts.add(chapterIndex)) {
coroutineScope.launch(Dispatchers.IO) {
paginatorScope.launch(Dispatchers.IO) {
updateAndSaveConfigurationCache()
}
}
@ -1041,6 +1183,7 @@ class BookPaginator(
}
override fun getPageContent(pageIndex: Int): Page? {
if (isDisposed()) return null
Timber.v("getPageContent requested for pageIndex $pageIndex")
val chapterIndex = findChapterIndexForPage(pageIndex)
if (chapterIndex == null) {
@ -1127,8 +1270,13 @@ class BookPaginator(
}
private suspend fun paginateChapter(chapterIndex: Int): List<Page>? {
coroutineContext.ensureActive()
if (isDisposed()) return null
pageCache[chapterIndex]?.let {
Timber.d("paginateChapter: L1 Cache HIT for chapter $chapterIndex in MEMORY, returning cached pages.")
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"page_memory_cache_hit chapter=$chapterIndex " + it.readerPagesLinkDiagSummary()
)
return it
}
@ -1142,12 +1290,9 @@ class BookPaginator(
return it
}
val blocks = blockCache[chapterIndex] ?: run {
Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
val blocksFromDb = getBlocksForChapter(chapter, chapterIndex)
blockCache.put(chapterIndex, blocksFromDb) // Store in L2 cache
blocksFromDb
}
val blocks = getCachedBlocksForChapter(chapter, chapterIndex)
coroutineContext.ensureActive()
if (isDisposed()) return null
Timber.d("paginateChapter: Chapter $chapterIndex retrieved/parsed into ${blocks.size} content blocks.")
@ -1165,7 +1310,12 @@ class BookPaginator(
measurementProvider = measurementProvider,
density = density
)
coroutineContext.ensureActive()
if (isDisposed()) return null
Timber.d("paginateChapter: PaginatorLogic returned ${pages.size} pages for chapter $chapterIndex.")
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"pagination_result chapter=$chapterIndex " + pages.readerPagesLinkDiagSummary()
)
applyPageRuntimeIndexes(chapterIndex, pages)
savePageCacheAsync(chapter, chapterIndex, pages)
@ -1177,6 +1327,7 @@ class BookPaginator(
}
private fun triggerPagination(chapterIndex: Int, priority: Int) {
if (isDisposed()) return
if (chapterIndex !in chapters.indices) {
Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
return
@ -1209,6 +1360,7 @@ class BookPaginator(
}
private fun prefetchChapters(currentChapterIndex: Int) {
if (isDisposed()) return
Timber.v("Prefetching chapters around index $currentChapterIndex.")
for (offset in 1..2) {
val nextChapterIndex = currentChapterIndex + offset
@ -1307,12 +1459,32 @@ class BookPaginator(
finalPage
}
suspend fun findStableLocatorForAnchor(chapterIndex: Int, anchor: String?): Locator? = withContext(Dispatchers.IO) {
if (anchor.isNullOrBlank()) return@withContext Locator(chapterIndex, 0, 0)
val requestedChapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
val requestedBlocks = getCachedBlocksForChapter(requestedChapter, chapterIndex)
findLocatorForAnchorInBlocks(chapterIndex, anchor, requestedBlocks)?.let { locator ->
return@withContext locator
}
val indexEntry = bookCacheDao.getAnchorIndex(bookId, anchor)
val targetChapter = indexEntry?.chapterIndex ?: chapterIndex
val chapter = chapters.getOrNull(targetChapter) ?: return@withContext null
val blocks = getCachedBlocksForChapter(chapter, targetChapter)
findLocatorForAnchorInBlocks(targetChapter, anchor, blocks)
?: indexEntry?.let { Locator(it.chapterIndex, it.blockIndex, 0) }
}
override fun findPageForAnchor(
chapterIndex: Int,
anchor: String?,
onResult: (pageIndex: Int) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
if (isDisposed()) return
paginatorScope.launch(Dispatchers.IO) {
if (isDisposed()) return@launch
val page = findStablePageForAnchor(chapterIndex, anchor) ?: return@launch
withContext(Dispatchers.Main) { onResult(page) }
}
@ -1365,7 +1537,9 @@ class BookPaginator(
href: String,
onNavigationComplete: (pageIndex: Int) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
if (isDisposed()) return
paginatorScope.launch(Dispatchers.IO) {
if (isDisposed()) return@launch
val targetPage = findStablePageForHref(currentChapterAbsPath, href) ?: return@launch
withContext(Dispatchers.Main) { onNavigationComplete(targetPage) }
}
@ -1389,10 +1563,33 @@ class BookPaginator(
findStablePageForAnchor(targetChapterIndex, anchor)
}
suspend fun findStableLocatorForHref(currentChapterAbsPath: String, href: String): Locator? = withContext(Dispatchers.IO) {
val (targetChapterPath, anchor) = resolveHref(currentChapterAbsPath, href)
if (targetChapterPath == null) {
Timber.w("Could not resolve href '$href' to a valid chapter path.")
return@withContext null
}
val targetChapterIndex = chapters.indexOfFirst { it.absPath == targetChapterPath }
if (targetChapterIndex == -1) {
Timber.w("Could not find chapter for path: $targetChapterPath")
return@withContext null
}
findStableLocatorForAnchor(targetChapterIndex, anchor)
}
suspend fun findStablePageForSearchResult(result: SearchResult): Int? = withContext(Dispatchers.IO) {
val targetChapterIndex = result.locationInSource
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
findStableLocatorForSearchResult(result)?.let { locator ->
findStablePageForLocator(locator)?.let { page ->
Timber.i("Found exact search result locator $locator on absolute page $page")
return@withContext page
}
}
val chapterPages = ensureChapterPaginated(targetChapterIndex)
val chapterStartPage = ensureStableStartPageForChapter(targetChapterIndex)
@ -1435,8 +1632,16 @@ class BookPaginator(
finalPageIndex
}
suspend fun findStableLocatorForSearchResult(result: SearchResult): Locator? = withContext(Dispatchers.IO) {
val chapter = chapters.getOrNull(result.locationInSource) ?: return@withContext null
val blocks = getCachedBlocksForChapter(chapter, result.locationInSource)
findLocatorForSearchResultInBlocks(result, blocks)
}
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
coroutineScope.launch(Dispatchers.IO) {
if (isDisposed()) return
paginatorScope.launch(Dispatchers.IO) {
if (isDisposed()) return@launch
val page = findStablePageForSearchResult(result) ?: return@launch
withContext(Dispatchers.Main) { onResult(page) }
}
@ -1617,7 +1822,9 @@ class BookPaginator(
}
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) {
coroutineScope.launch(Dispatchers.IO) {
if (isDisposed()) return
paginatorScope.launch(Dispatchers.IO) {
if (isDisposed()) return@launch
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
val chapterPages = ensureChapterPaginated(chapterIndex)

View file

@ -122,7 +122,11 @@ class ContentStyler(
return when (block) {
is SemanticParagraph -> {
val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign
val computedTextAlign = when {
userTextAlign != null -> userTextAlign
themedStyle.paragraphStyle.textAlign == TextAlign.Justify -> TextAlign.Left
else -> themedStyle.paragraphStyle.textAlign
}
ParagraphBlock(
content = buildAnnotatedString(block, themedStyle),
@ -411,7 +415,10 @@ class ContentStyler(
withStyle(finalParagraphStyle) {
withStyle(initialSpanStyle) {
append(block.text)
val linkSpans = mutableListOf<SemanticSpan>()
block.spans.sortedBy { it.start }.forEach { span ->
val spanStart = span.start.coerceIn(0, block.text.length)
val spanEnd = span.end.coerceIn(spanStart, block.text.length)
val themedSpanStyle = applyThemeToStyle(span.style)
val spanFontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
val effectiveSpanFontFamily = if (spanFontFamily == FontFamily.Monospace) {
@ -434,6 +441,7 @@ class ContentStyler(
)
if (!span.linkHref.isNullOrBlank()) {
linkSpans.add(span)
finalSpanStyle = finalSpanStyle.withReaderLinkStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
@ -459,31 +467,58 @@ class ContentStyler(
val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0"
val annotationData = "$styleStr|$colorStr|$offsetStr"
addStringAnnotation("CustomUnderline", annotationData, span.start, span.end)
if (spanStart < spanEnd) {
addStringAnnotation("CustomUnderline", annotationData, spanStart, spanEnd)
}
}
addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end)
if (spanStart < spanEnd) {
addStyle(initialSpanStyle.merge(finalSpanStyle), spanStart, spanEnd)
}
val ws = themedSpanStyle.wordSpacing
if (ws.isSpecified && ws.value != 0f) {
val textToStyle = block.text.substring(span.start, span.end)
if (ws.isSpecified && ws.value != 0f && spanStart < spanEnd) {
val textToStyle = block.text.substring(spanStart, spanEnd)
for (i in textToStyle.indices) {
if (textToStyle[i] == ' ') {
addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1)
addStyle(SpanStyle(letterSpacing = ws), spanStart + i, spanStart + i + 1)
}
}
}
span.linkHref?.let { linkHref ->
addStringAnnotation("URL", linkHref, span.start, span.end)
span.linkHref?.takeIf { it.isNotBlank() }?.let { linkHref ->
if (spanStart < spanEnd) {
addStringAnnotation("URL", linkHref, spanStart, spanEnd)
}
}
span.elementId?.let { elementId ->
addStringAnnotation("ID", elementId, span.start, span.end)
addStringAnnotation("ID", elementId, spanStart, spanEnd)
}
}
val forcedLinkStyle = readerLinkSpanStyle(
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
linkSpans.forEach { span ->
val start = span.start.coerceIn(0, block.text.length)
val end = span.end.coerceIn(start, block.text.length)
if (start < end) {
addStyle(forcedLinkStyle, start, end)
}
}
}
}
}
if (block.spans.any { !it.linkHref.isNullOrBlank() }) {
Timber.tag(TAG_PAGINATED_LINK_DIAG).d(
"style_text_block type=${block::class.simpleName ?: "Text"} " +
"block=${block.blockIndex} cfi=${block.cfi} " +
"rawLinkSpans=${block.spans.count { !it.linkHref.isNullOrBlank() }} " +
builtString.readerAnnotatedLinkDiagSummary()
)
}
return builtString.maybeAdjustLineHeightForEmphasis()
}
@ -562,7 +597,10 @@ class ContentStyler(
fontFamilyMap: Map<String, FontFamily>
): FontFamily? {
if (fontFamilyNames.isEmpty()) return null
val specificFont = fontFamilyNames.firstNotNullOfOrNull { fontFamilyMap[it] }
val normalizedMap = fontFamilyMap.entries.associate { it.key.trim().lowercase() to it.value }
val specificFont = fontFamilyNames.firstNotNullOfOrNull { name ->
normalizedMap[name.trim().removeSurrounding("\"").removeSurrounding("'").lowercase()]
}
if (specificFont != null) return specificFont
return fontFamilyNames.firstNotNullOfOrNull { name -> FontFamilyMapper.nameToFontFamily(name) }
}

View file

@ -53,4 +53,5 @@ interface IPaginator {
fun getCfiForPage(pageIndex: Int): String?
fun onUserScrolledTo(pageIndex: Int)
fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String?
}
fun dispose() = Unit
}

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.ProcessedChapter
@ -36,6 +37,9 @@ import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import java.io.File
private const val MAX_LOCATOR_ON_DEMAND_HTML_BYTES = 2L * 1024L * 1024L
private const val MAX_LOCATOR_ON_DEMAND_HTML_CHARS = 2 * 1024 * 1024
data class Locator(
val chapterIndex: Int,
val blockIndex: Int,
@ -66,21 +70,9 @@ class LocatorConverter(
try {
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
val htmlToParse = chapter.htmlContent.ifBlank {
try {
val file = File(book.extractionBasePath, chapter.contentFilePath())
if (file.exists()) {
val content = file.readText()
content
} else {
""
}
} catch (_: Exception) {
""
}
}
val htmlToParse = readChapterHtmlForLocator(book, chapter, chapterIndex)
if (htmlToParse.isBlank()) {
if (htmlToParse.isNullOrBlank()) {
return@withContext null
}
@ -149,22 +141,77 @@ class LocatorConverter(
)
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
semanticBlocks
} catch (e: OutOfMemoryError) {
Timber.e(e, "Out of memory while processing locator cache for chapter $chapterIndex")
null
} catch (_: Exception) {
null
}
}
private fun readChapterHtmlForLocator(
book: EpubBook,
chapter: EpubChapter,
chapterIndex: Int
): String? {
chapter.htmlContent.takeIf { it.isNotBlank() }?.let { inlineHtml ->
if (inlineHtml.length > MAX_LOCATOR_ON_DEMAND_HTML_CHARS) {
Timber.w(
"Skipping on-demand locator processing for chapter $chapterIndex: " +
"inline HTML is ${inlineHtml.length} chars"
)
return null
}
return inlineHtml
}
return try {
val file = File(book.extractionBasePath, chapter.contentFilePath())
if (!file.isFile) return null
if (file.length() > MAX_LOCATOR_ON_DEMAND_HTML_BYTES) {
Timber.w(
"Skipping on-demand locator processing for chapter $chapterIndex: " +
"HTML file is ${file.length()} bytes"
)
return null
}
file.bufferedReader().use { it.readText() }
} catch (_: Exception) {
null
}
}
private fun decodeCachedBlocks(
processedChapter: ProcessedChapter?,
chapterIndex: Int
): List<SemanticBlock>? {
if (processedChapter == null || processedChapter.contentBlocksProto.isEmpty()) {
return null
}
return try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} catch (e: OutOfMemoryError) {
Timber.e(e, "Out of memory while decoding locator cache for chapter $chapterIndex")
null
} catch (_: Exception) {
null
}
}
private suspend fun getProcessedChapterSafely(bookId: String, chapterIndex: Int): ProcessedChapter? {
return try {
bookCacheDao.getProcessedChapter(bookId = bookId, chapterIndex = chapterIndex)
} catch (e: OutOfMemoryError) {
Timber.e(e, "Out of memory while loading locator cache for chapter $chapterIndex")
null
}
}
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
allBlocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} catch (_: Exception) { null }
}
var allBlocks = decodeCachedBlocks(processedChapter, chapterIndex)
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
@ -174,17 +221,33 @@ class LocatorConverter(
return@withContext null
}
val (baseCfiPath, charOffset) = cfi.split(':').let {
it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 0)
val firstCfiPoint = cfi.substringBefore('|')
val cfiOffsetSeparator = firstCfiPoint.lastIndexOf(':')
val baseCfiPath = if (cfiOffsetSeparator > 0) {
firstCfiPoint.substring(0, cfiOffsetSeparator)
} else {
firstCfiPoint
}
val charOffset = if (cfiOffsetSeparator > 0 && cfiOffsetSeparator < firstCfiPoint.lastIndex) {
firstCfiPoint.substring(cfiOffsetSeparator + 1).toIntOrNull() ?: 0
} else {
0
}
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
if (bestMatch != null) {
val absoluteCharOffset = when (bestMatch) {
is SemanticTextBlock -> {
val localOffset = charOffset.coerceIn(0, bestMatch.text.length)
bestMatch.startCharOffsetInSource + localOffset
}
else -> charOffset.coerceAtLeast(0)
}
val locator = Locator(
chapterIndex = chapterIndex,
blockIndex = bestMatch.blockIndex,
charOffset = charOffset
charOffset = absoluteCharOffset
)
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Successfully resolved to $locator")
locator
@ -238,14 +301,9 @@ class LocatorConverter(
}
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
allBlocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} catch (_: Exception) { null }
}
var allBlocks = decodeCachedBlocks(processedChapter, chapterIndex)
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
@ -294,14 +352,9 @@ class LocatorConverter(
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
var blocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
blocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} catch (_: Exception) { null }
}
var blocks = decodeCachedBlocks(processedChapter, locator.chapterIndex)
if (blocks.isNullOrEmpty()) {
blocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
@ -313,8 +366,20 @@ class LocatorConverter(
val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex)
val resultCfi = foundBlock?.cfi?.let { cfi ->
if (locator.charOffset > 0) {
"$cfi:${locator.charOffset}"
val localOffset = when (foundBlock) {
is SemanticTextBlock -> {
val start = foundBlock.startCharOffsetInSource
val end = start + foundBlock.text.length
if (locator.charOffset in start..end) {
locator.charOffset - start
} else {
locator.charOffset
}.coerceIn(0, foundBlock.text.length)
}
else -> locator.charOffset.coerceAtLeast(0)
}
if (localOffset > 0) {
"$cfi:$localOffset"
} else {
cfi
}
@ -363,14 +428,9 @@ class LocatorConverter(
}
suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
val processedChapter = getProcessedChapterSafely(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
allBlocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} catch(_: Exception) { null }
}
var allBlocks = decodeCachedBlocks(processedChapter, locator.chapterIndex)
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
@ -384,7 +444,19 @@ class LocatorConverter(
fun traverse(blocks: List<SemanticBlock>): Boolean {
for (block in blocks) {
if (block.blockIndex == locator.blockIndex) {
offset += locator.charOffset
val absoluteOffset = when (block) {
is SemanticTextBlock -> {
val start = block.startCharOffsetInSource
val end = start + block.text.length
locator.charOffset.takeIf { (start > 0 || offset == 0) && it in start..end }
}
else -> null
}
if (absoluteOffset != null) {
offset = absoluteOffset
} else {
offset += locator.charOffset
}
return true
}

View file

@ -29,6 +29,7 @@ import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import com.aryan.reader.BuildConfig
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
@ -182,11 +183,18 @@ class MathMLRenderer(private val context: Context) {
val mathMLForJs = job.mathML.replace("`", "\\`")
val script = """
(function() {
console.log("MATH_DIAGNOSTIC: Starting MathML to SVG conversion.");
var mathDiagnosticsEnabled = ${BuildConfig.DEBUG};
function mathLog() {
if (mathDiagnosticsEnabled) console.log.apply(console, arguments);
}
function mathError() {
if (mathDiagnosticsEnabled) console.error.apply(console, arguments);
}
mathLog("MATH_DIAGNOSTIC: Starting MathML to SVG conversion.");
const mathMLContent = `${mathMLForJs}`;
console.log("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent);
mathLog("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent);
MathJax.mathml2svgPromise(mathMLContent).then(function (node) {
console.log("MATH_DIAGNOSTIC: mathml2svgPromise successful.");
mathLog("MATH_DIAGNOSTIC: mathml2svgPromise successful.");
var svgElement = node.querySelector('svg');
if (svgElement) {
svgElement.style.fill = 'currentColor';
@ -194,15 +202,15 @@ class MathMLRenderer(private val context: Context) {
var width = svgElement.getAttribute('width');
var height = svgElement.getAttribute('height');
var viewBox = svgElement.getAttribute('viewBox');
console.log('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length);
console.log("MATH_DIAGNOSTIC: SVG generated: " + svgOutput);
mathLog('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length);
mathLog("MATH_DIAGNOSTIC: SVG generated: " + svgOutput);
AndroidBridge.onSvgReady(svgOutput);
} else {
console.error("MATH_DIAGNOSTIC: SVG element not found in MathJax output.");
mathError("MATH_DIAGNOSTIC: SVG element not found in MathJax output.");
AndroidBridge.onSvgReady('');
}
}).catch((err) => {
console.error("MATH_DIAGNOSTIC: MathJax conversion error:", err);
mathError("MATH_DIAGNOSTIC: MathJax conversion error:", err);
AndroidBridge.onSvgReady('');
});
})();

View file

@ -58,6 +58,7 @@ class PaginatedReaderViewModel : ViewModel() {
@VisibleForTesting
internal fun setPaginatorForTest(testPaginator: IPaginator) {
paginator?.dispose()
paginator = testPaginator
observePaginatorState()
}
@ -177,4 +178,9 @@ class PaginatedReaderViewModel : ViewModel() {
fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) {
paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete)
}
override fun onCleared() {
paginator?.dispose()
super.onCleared()
}
}

View file

@ -20,12 +20,17 @@
package com.aryan.reader.paginatedreader
import android.os.Build
import android.util.Log
import com.aryan.reader.BuildConfig
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
@ -34,11 +39,66 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.coroutines.coroutineContext
private const val DEBUG_PAGINATION_LOGS = false
private const val AndroidEpubCutoffLogTag = "EpistemeEpubCutoff"
private const val JustifiedSplitGapProbeMinFraction = 0.18f
internal fun measuredTextHeightForPagination(
layoutHeightPx: Int,
lastLineBottomPx: Float
): Int {
return maxOf(layoutHeightPx, ceil(lastLineBottomPx.toDouble()).toInt())
}
private fun TextLayoutResult.paginationMeasuredHeightPx(): Int {
val lastLineBottomPx = if (lineCount > 0) getLineBottom(lineCount - 1) else 0f
return measuredTextHeightForPagination(size.height, lastLineBottomPx)
}
private fun logAndroidEpubCutoff(message: String) {
if (!BuildConfig.DEBUG) return
Log.d(AndroidEpubCutoffLogTag, message)
}
private fun CharSequence.firstWordOrEmpty(): String {
var start = 0
while (start < length && this[start].isWhitespace()) start++
if (start >= length) return ""
var end = start
while (end < length && !this[end].isWhitespace()) end++
return subSequence(start, end).toString()
}
private fun CharSequence.skipWhitespaceFrom(index: Int): Int {
var current = index.coerceIn(0, length)
while (current < length && this[current].isWhitespace()) current++
return current
}
private fun CharSequence.trimTrailingWhitespaceBefore(index: Int): Int {
var current = index.coerceIn(0, length)
while (current > 0 && this[current - 1].isWhitespace()) current--
return current
}
private fun CharSequence.nextWordEndAfter(index: Int): Int {
var current = skipWhitespaceFrom(index)
while (current < length && !this[current].isWhitespace()) current++
return current
}
private fun CharSequence.previousWordEndBefore(index: Int): Int {
var current = trimTrailingWhitespaceBefore(index)
while (current > 0 && !this[current - 1].isWhitespace()) current--
return trimTrailingWhitespaceBefore(current)
}
interface BlockMeasurementProvider {
suspend fun measure(block: ContentBlock): Int
@ -59,6 +119,7 @@ class SuspendingAndroidBlockMeasurementProvider(
private val measurementCache = ConcurrentHashMap<Int, Int>()
override suspend fun measure(block: ContentBlock): Int {
coroutineContext.ensureActive()
val cacheKey = blockMeasurementCacheKey(block)
measurementCache[cacheKey]?.let { return it }
@ -85,6 +146,7 @@ class SuspendingAndroidBlockMeasurementProvider(
}
override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>? {
coroutineContext.ensureActive()
return splitParagraphBlock(
block = block,
textMeasurer = textMeasurer,
@ -96,6 +158,7 @@ class SuspendingAndroidBlockMeasurementProvider(
}
override suspend fun split(block: WrappingContentBlock, availableHeight: Int): Pair<WrappingContentBlock, List<ContentBlock>>? {
coroutineContext.ensureActive()
val imageBlock = block.floatedImage
val (imageWidthPx, imageHeightPx) = run {
@ -145,6 +208,7 @@ class SuspendingAndroidBlockMeasurementProvider(
val wrappingContentWidth = (constraints.maxWidth - imageWidthPx).toInt().coerceAtLeast(0)
while (textOffset < fullText.length) {
coroutineContext.ensureActive()
val isBesideImage = currentY < imageHeightPx
val currentMaxWidth = if (isBesideImage) wrappingContentWidth else constraints.maxWidth
@ -212,6 +276,7 @@ class SuspendingAndroidBlockMeasurementProvider(
var splitOccurred = false
for ((index, paraRange) in paragraphOffsets.withIndex()) {
coroutineContext.ensureActive()
val originalPara = block.paragraphsToWrap[index]
if (splitOccurred) {
@ -287,6 +352,7 @@ class SuspendingAndroidBlockMeasurementProvider(
}
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? {
coroutineContext.ensureActive()
var currentHeight = 0
var splitRowIndex = -1
@ -304,17 +370,28 @@ class SuspendingAndroidBlockMeasurementProvider(
currentHeight += decorationTop
for (i in block.rows.indices) {
coroutineContext.ensureActive()
val row = block.rows[i]
var maxRowHeight = 0
val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f)
row.forEach { cell ->
for (cell in row) {
coroutineContext.ensureActive()
val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).roundToInt()
@Suppress("UnusedVariable", "Unused") val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0))
val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0))
var cellHeight = 0
cell.content.forEach { b ->
cellHeight += measure(b)
for (b in cell.content) {
coroutineContext.ensureActive()
cellHeight += measureBlockHeight(
block = b,
textMeasurer = textMeasurer,
constraints = cellConstraints,
defaultStyle = textStyle,
headerStyle = textStyle.copy(fontWeight = FontWeight.Bold),
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
}
val cellDecoration = with(density) {
cell.style.blockStyle.padding.top.toPx() + cell.style.blockStyle.padding.bottom.toPx() +
@ -346,6 +423,7 @@ class SuspendingAndroidBlockMeasurementProvider(
}
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? {
coroutineContext.ensureActive()
if (block.style.flexDirection == "row") return null
var currentHeight = 0
@ -362,6 +440,7 @@ class SuspendingAndroidBlockMeasurementProvider(
currentHeight += decorationTop
for (i in block.children.indices) {
coroutineContext.ensureActive()
val child = block.children[i]
val childHeight = measure(child)
val margin = with(density) {
@ -422,6 +501,15 @@ private fun <T : ContentBlock> setBlockExpectedHeight(block: T, height: Int): T
} as T
}
private fun BlockStyle.avoidsBreakInside(): Boolean =
pageBreakInsideAvoid || breakInside in setOf("avoid", "avoid-page", "avoid-column")
private fun BlockStyle.forcesBreakBefore(): Boolean =
breakBefore in setOf("page", "always", "left", "right", "recto", "verso")
private fun BlockStyle.forcesBreakAfter(): Boolean =
breakAfter in setOf("page", "always", "left", "right", "recto", "verso")
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
suspend fun paginate(
blocks: List<ContentBlock>,
@ -444,8 +532,19 @@ suspend fun paginate(
val safetyMarginPerBlock = 0
while (remainingBlocks.isNotEmpty()) {
coroutineContext.ensureActive()
val block = remainingBlocks.removeAt(0)
if (currentPageContent.isNotEmpty() && block.style.forcesBreakBefore()) {
zeroOutBottomMargin(currentPageContent)
pages.add(Page(content = currentPageContent.toList()))
pageIndex++
currentPageContent = mutableListOf()
remainingHeight = pageHeight
remainingBlocks.add(0, block)
continue
}
val blockHeight = measurementProvider.measure(block)
val blockHeightWithSafetyMargin = blockHeight + safetyMarginPerBlock
@ -488,6 +587,14 @@ suspend fun paginate(
currentPageContent.add(blockToAdd)
remainingHeight -= spaceRequired
if (block.style.forcesBreakAfter() && remainingBlocks.isNotEmpty()) {
zeroOutBottomMargin(currentPageContent)
pages.add(Page(content = currentPageContent.toList()))
pageIndex++
currentPageContent = mutableListOf()
remainingHeight = pageHeight
}
} else {
var wasSplit = false
val heightForSplitting = remainingHeight - spaceBetweenBlocks
@ -495,7 +602,7 @@ suspend fun paginate(
if (heightForSplitting > 50) {
when (block) {
is ParagraphBlock -> {
if (!block.style.pageBreakInsideAvoid) {
if (!block.style.avoidsBreakInside()) {
measurementProvider.split(block, heightForSplitting)
?.let { (part1, part2) ->
if (part1.content.isNotEmpty()) {
@ -534,7 +641,7 @@ suspend fun paginate(
}
is WrappingContentBlock -> {
measurementProvider.split(block, heightForSplitting)
if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting)
?.let { (part1, part2) ->
if (part1.paragraphsToWrap.any { it.content.isNotBlank() }) {
val collapsedMarginDp =
@ -570,7 +677,7 @@ suspend fun paginate(
}
is TableBlock -> {
measurementProvider.split(block, heightForSplitting)
if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting)
?.let { (part1, part2) ->
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() }
if (currentPageContent.isNotEmpty()) {
@ -602,7 +709,7 @@ suspend fun paginate(
}
is FlexContainerBlock -> {
measurementProvider.split(block, heightForSplitting)
if (!block.style.avoidsBreakInside()) measurementProvider.split(block, heightForSplitting)
?.let { (part1, part2) ->
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() }
if (currentPageContent.isNotEmpty()) {
@ -692,6 +799,7 @@ private suspend fun measureBlockHeight(
density: Density,
imageSizeMultiplier: Float = 1.0f
): Int {
coroutineContext.ensureActive()
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
val verticalPaddingPx = boxMetrics.verticalPaddingPx
val verticalBorderPx = boxMetrics.verticalBorderPx
@ -705,7 +813,7 @@ private suspend fun measureBlockHeight(
text = block.content,
style = paragraphStyle,
constraints = adjustedConstraints
).size.height
).paginationMeasuredHeightPx()
}
height + centeredTextSafetyPaddingPx(paragraphStyle, density)
}
@ -718,7 +826,7 @@ private suspend fun measureBlockHeight(
text = block.content,
style = style,
constraints = adjustedConstraints
).size.height
).paginationMeasuredHeightPx()
}
height + centeredTextSafetyPaddingPx(style, density)
}
@ -731,7 +839,9 @@ private suspend fun measureBlockHeight(
) ?: with(density) { 250.dp.toPx() }
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
}
finalHeight
}
is SpacerBlock -> {
@ -745,7 +855,7 @@ private suspend fun measureBlockHeight(
text = block.content,
style = quoteStyle,
constraints = adjustedConstraints
).size.height
).paginationMeasuredHeightPx()
}
height + centeredTextSafetyPaddingPx(quoteStyle, density)
}
@ -759,7 +869,7 @@ private suspend fun measureBlockHeight(
text = block.content,
style = defaultStyle,
constraints = textConstraints
).size.height
).paginationMeasuredHeightPx()
}
val markerImageHeight = if (block.itemMarkerImage != null) {
with(density) { (defaultStyle.fontSize.value * 0.8f).sp.toPx().roundToInt() }
@ -771,11 +881,13 @@ private suspend fun measureBlockHeight(
}
is TableBlock -> {
var totalHeight = 0
block.rows.forEach { row ->
for (row in block.rows) {
coroutineContext.ensureActive()
var maxRowHeight = 0
val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f)
row.forEach { cell ->
for (cell in row) {
coroutineContext.ensureActive()
val cellBlockStyle = cell.style.blockStyle
val cellMaxWidth = when {
cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx().roundToInt() }
@ -844,6 +956,7 @@ private suspend fun measureBlockHeight(
// Loop until all text is measured.
while (textOffset < fullText.length) {
coroutineContext.ensureActive()
val isBesideImage = currentY < imageHeightPx
val currentMaxWidth = if (isBesideImage) {
wrappingContentWidth
@ -936,11 +1049,19 @@ private suspend fun measureBlockHeight(
}
}
val specifiedHeightDp = block.style.height
val finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) {
var finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) {
with(density) { specifiedHeightDp.toPx().roundToInt() }
} else {
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
}
with(density) {
if (block.style.minHeight.isSpecified) {
finalHeight = finalHeight.coerceAtLeast(block.style.minHeight.toPx().roundToInt())
}
if (block.style.maxHeight.isSpecified && block.style.overflow in setOf("hidden", "clip", "scroll", "auto")) {
finalHeight = finalHeight.coerceAtMost(block.style.maxHeight.toPx().roundToInt())
}
}
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
@ -948,6 +1069,380 @@ private suspend fun measureBlockHeight(
return finalHeight
}
private suspend fun logJustifiedSplitGapIfSuspicious(
block: ParagraphBlock,
text: AnnotatedString,
textMeasurer: TextMeasurer,
paragraphStyle: TextStyle,
paragraphConstraints: Constraints,
layoutResult: TextLayoutResult,
lastVisibleLine: Int,
splitOffset: Int,
availableTextHeight: Int
) {
coroutineContext.ensureActive()
val isJustified = block.textAlign == TextAlign.Justify ||
paragraphStyle.textAlign == TextAlign.Justify ||
text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify }
if (!isJustified || lastVisibleLine !in 0 until layoutResult.lineCount) return
val lineStart = layoutResult.getLineStart(lastVisibleLine)
val lineEnd = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
if (lineStart >= lineEnd || lineEnd > text.length) return
val visibleRightPx = (lineStart until lineEnd)
.asSequence()
.filter { !text[it].isWhitespace() }
.mapNotNull { index ->
runCatching { layoutResult.getBoundingBox(index).right }.getOrNull()
}
.maxOrNull() ?: return
val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return
val visualGapPx = contentWidthPx - visibleRightPx
if (visualGapPx < contentWidthPx * JustifiedSplitGapProbeMinFraction) return
val nextWord = text.text.subSequence(splitOffset.coerceIn(0, text.length), text.length)
.firstWordOrEmpty()
.take(48)
if (nextWord.isBlank()) return
val lineText = text.text.substring(lineStart, lineEnd).trimEnd()
val candidateLineCount = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = "$lineText $nextWord",
style = paragraphStyle,
constraints = paragraphConstraints
).lineCount
}
coroutineContext.ensureActive()
logAndroidEpubCutoff(
"cutoff_probe layer=android_justified_split_gap block=${block.blockIndex} " +
"line=$lastVisibleLine lineOffsets=$lineStart..$lineEnd splitOffset=$splitOffset " +
"sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " +
"contentWidthPx=$contentWidthPx visibleRightPx=${visibleRightPx.roundToInt()} " +
"visualGapPx=${visualGapPx.roundToInt()} availableTextHeightPx=$availableTextHeight " +
"nextWordChars=${nextWord.length} candidateLineCount=$candidateLineCount " +
"note=justify_expands_spaces_so_visual_gap_may_not_be_fit_capacity"
)
}
private suspend fun logRenderedJustifiedSplitGapIfSuspicious(
block: ParagraphBlock,
part1Text: AnnotatedString,
part2Text: AnnotatedString,
textMeasurer: TextMeasurer,
paragraphStyle: TextStyle,
paragraphConstraints: Constraints,
originalLayoutResult: TextLayoutResult,
originalLastVisibleLine: Int,
splitOffset: Int,
availableTextHeight: Int
) {
coroutineContext.ensureActive()
val isJustified = block.textAlign == TextAlign.Justify ||
paragraphStyle.textAlign == TextAlign.Justify ||
part1Text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify }
if (!isJustified || part1Text.isEmpty()) return
val renderedPart1Layout = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = part1Text,
style = paragraphStyle,
constraints = paragraphConstraints
)
}
coroutineContext.ensureActive()
val renderedLastLine = renderedPart1Layout.lineCount - 1
if (renderedLastLine < 0) return
val renderedLineStart = renderedPart1Layout.getLineStart(renderedLastLine)
val renderedLineEnd = renderedPart1Layout.getLineEnd(renderedLastLine, visibleEnd = true)
if (renderedLineStart >= renderedLineEnd || renderedLineEnd > part1Text.length) return
val visibleRightPx = (renderedLineStart until renderedLineEnd)
.asSequence()
.filter { !part1Text[it].isWhitespace() }
.mapNotNull { index ->
runCatching { renderedPart1Layout.getBoundingBox(index).right }.getOrNull()
}
.maxOrNull() ?: return
val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return
val visualGapPx = contentWidthPx - visibleRightPx
val renderedLineText = part1Text.text.substring(renderedLineStart, renderedLineEnd).trim()
val renderedLineWordCount = renderedLineText.split(Regex("\\s+")).count { it.isNotBlank() }
val sparseByGap = visualGapPx >= contentWidthPx * 0.10f
val sparseByWords = renderedLineWordCount <= 4 && visualGapPx >= contentWidthPx * 0.06f
if (!sparseByGap && !sparseByWords) return
val nextWord = part2Text.text.firstWordOrEmpty().take(48)
if (nextWord.isBlank()) return
val visualCandidateLineCount = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = "$renderedLineText $nextWord",
style = paragraphStyle,
constraints = paragraphConstraints
).lineCount
}
val part2LineCount = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = part2Text,
style = paragraphStyle,
constraints = paragraphConstraints
).lineCount
}
val nextWordEnd = part2Text.text.nextWordEndAfter(0)
val remainingAfterNextWordStart = part2Text.text.skipWhitespaceFrom(nextWordEnd)
val remainingAfterNextWordLineCount = if (remainingAfterNextWordStart < part2Text.length) {
withContext(Dispatchers.Main) {
textMeasurer.measure(
text = part2Text.subSequence(remainingAfterNextWordStart, part2Text.length),
style = paragraphStyle,
constraints = paragraphConstraints
).lineCount
}
} else {
0
}
val originalLineStart = if (originalLastVisibleLine in 0 until originalLayoutResult.lineCount) {
originalLayoutResult.getLineStart(originalLastVisibleLine)
} else {
-1
}
val originalLineEnd = if (originalLastVisibleLine in 0 until originalLayoutResult.lineCount) {
originalLayoutResult.getLineEnd(originalLastVisibleLine, visibleEnd = true)
} else {
-1
}
coroutineContext.ensureActive()
logAndroidEpubCutoff(
"cutoff_probe layer=android_justified_split_gap block=${block.blockIndex} " +
"sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " +
"splitOffset=$splitOffset availableTextHeightPx=$availableTextHeight " +
"renderedLines=${renderedPart1Layout.lineCount} renderedLastLine=$renderedLastLine " +
"renderedLineOffsets=$renderedLineStart..$renderedLineEnd " +
"renderedLineChars=${renderedLineText.length} renderedLineWords=$renderedLineWordCount " +
"contentWidthPx=$contentWidthPx renderedVisibleRightPx=${visibleRightPx.roundToInt()} " +
"renderedVisualGapPx=${visualGapPx.roundToInt()} nextWordChars=${nextWord.length} " +
"visualCandidateLineCount=$visualCandidateLineCount part2Lines=$part2LineCount " +
"remainingAfterNextWordLines=$remainingAfterNextWordLineCount " +
"originalLastLine=$originalLastVisibleLine originalLineOffsets=$originalLineStart..$originalLineEnd " +
"note=rendered_split_final_line_is_unjustified_so_gap_can_appear_after_pagination"
)
}
private data class RenderedSplitCandidate(
val splitOffset: Int,
val prefixHeightPx: Int,
val prefixLineCount: Int,
val remainingLineCount: Int,
val lastLineChars: Int,
val lastLineWords: Int,
val lastLineVisualGapPx: Int,
val contentWidthPx: Int
) {
val sparseLastLine: Boolean
get() = lastLineVisualGapPx >= contentWidthPx * 0.20f ||
(lastLineWords <= 4 && lastLineVisualGapPx >= contentWidthPx * 0.08f)
}
private fun isBetterRenderedJustifySplitCandidate(
candidate: RenderedSplitCandidate,
current: RenderedSplitCandidate
): Boolean {
if (candidate.sparseLastLine != current.sparseLastLine) {
return !candidate.sparseLastLine
}
if (candidate.sparseLastLine) {
if (candidate.lastLineVisualGapPx != current.lastLineVisualGapPx) {
return candidate.lastLineVisualGapPx < current.lastLineVisualGapPx
}
return candidate.splitOffset > current.splitOffset
}
if (candidate.prefixLineCount != current.prefixLineCount) {
return candidate.prefixLineCount > current.prefixLineCount
}
if (candidate.lastLineVisualGapPx != current.lastLineVisualGapPx) {
return candidate.lastLineVisualGapPx < current.lastLineVisualGapPx
}
return candidate.splitOffset > current.splitOffset
}
private suspend fun measureRenderedSplitCandidate(
text: AnnotatedString,
textMeasurer: TextMeasurer,
paragraphStyle: TextStyle,
paragraphConstraints: Constraints,
splitOffset: Int
): RenderedSplitCandidate? {
val prefixEnd = text.text.trimTrailingWhitespaceBefore(splitOffset)
if (prefixEnd <= 0 || prefixEnd >= text.length) return null
val remainingStart = text.text.skipWhitespaceFrom(prefixEnd)
if (remainingStart >= text.length) return null
val prefixLayout = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = text.subSequence(0, prefixEnd),
style = paragraphStyle,
constraints = paragraphConstraints
)
}
coroutineContext.ensureActive()
val remainingLayout = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = text.subSequence(remainingStart, text.length),
style = paragraphStyle,
constraints = paragraphConstraints
)
}
coroutineContext.ensureActive()
val lastLine = prefixLayout.lineCount - 1
if (lastLine < 0) return null
val lineStart = prefixLayout.getLineStart(lastLine)
val lineEnd = prefixLayout.getLineEnd(lastLine, visibleEnd = true)
if (lineStart >= lineEnd || lineEnd > prefixEnd) return null
val prefixText = text.text.substring(0, prefixEnd)
val lastLineText = prefixText.substring(lineStart, lineEnd).trim()
val lastLineWords = lastLineText.split(Regex("\\s+")).count { it.isNotBlank() }
val contentWidthPx = paragraphConstraints.maxWidth.takeIf { it > 0 } ?: return null
val visibleRightPx = (lineStart until lineEnd)
.asSequence()
.filter { !prefixText[it].isWhitespace() }
.mapNotNull { index ->
runCatching { prefixLayout.getBoundingBox(index).right }.getOrNull()
}
.maxOrNull() ?: return null
val lastLineVisualGapPx = (contentWidthPx - visibleRightPx).roundToInt()
return RenderedSplitCandidate(
splitOffset = prefixEnd,
prefixHeightPx = prefixLayout.paginationMeasuredHeightPx(),
prefixLineCount = prefixLayout.lineCount,
remainingLineCount = remainingLayout.lineCount,
lastLineChars = lastLineText.length,
lastLineWords = lastLineWords,
lastLineVisualGapPx = lastLineVisualGapPx,
contentWidthPx = contentWidthPx
)
}
private suspend fun adjustJustifiedSplitOffsetForRenderedPrefix(
block: ParagraphBlock,
text: AnnotatedString,
textMeasurer: TextMeasurer,
paragraphStyle: TextStyle,
paragraphConstraints: Constraints,
initialSplitOffset: Int,
availableTextHeight: Int,
orphanLines: Int,
widowLines: Int
): Int? {
coroutineContext.ensureActive()
val isJustified = block.textAlign == TextAlign.Justify ||
paragraphStyle.textAlign == TextAlign.Justify ||
text.paragraphStyles.any { it.item.textAlign == TextAlign.Justify }
if (!isJustified) return initialSplitOffset
val normalizedInitialOffset = text.text.trimTrailingWhitespaceBefore(initialSplitOffset)
var candidateOffset = normalizedInitialOffset
var bestCandidate: RenderedSplitCandidate? = null
while (candidateOffset > 0) {
coroutineContext.ensureActive()
val candidate = measureRenderedSplitCandidate(
text = text,
textMeasurer = textMeasurer,
paragraphStyle = paragraphStyle,
paragraphConstraints = paragraphConstraints,
splitOffset = candidateOffset
)
if (candidate != null &&
candidate.prefixHeightPx <= availableTextHeight &&
candidate.prefixLineCount >= orphanLines &&
candidate.remainingLineCount >= widowLines
) {
bestCandidate = candidate
break
}
candidateOffset = text.text.previousWordEndBefore(candidateOffset)
}
if (bestCandidate == null) {
logAndroidEpubCutoff(
"cutoff_probe layer=android_justified_split_adjust block=${block.blockIndex} " +
"sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " +
"initialSplitOffset=$initialSplitOffset adjustedSplitOffset=null " +
"availableTextHeightPx=$availableTextHeight reason=no_rendered_prefix_fit"
)
return null
}
var acceptedCandidate: RenderedSplitCandidate = bestCandidate ?: return null
var furthestFittingCandidate: RenderedSplitCandidate = acceptedCandidate
while (true) {
coroutineContext.ensureActive()
val nextOffset = text.text.nextWordEndAfter(furthestFittingCandidate.splitOffset)
if (nextOffset <= furthestFittingCandidate.splitOffset || nextOffset >= text.length) break
val nextCandidate = measureRenderedSplitCandidate(
text = text,
textMeasurer = textMeasurer,
paragraphStyle = paragraphStyle,
paragraphConstraints = paragraphConstraints,
splitOffset = nextOffset
) ?: break
if (nextCandidate.prefixHeightPx > availableTextHeight ||
nextCandidate.prefixLineCount < orphanLines ||
nextCandidate.remainingLineCount < widowLines
) {
break
}
furthestFittingCandidate = nextCandidate
if (isBetterRenderedJustifySplitCandidate(nextCandidate, acceptedCandidate)) {
acceptedCandidate = nextCandidate
}
}
if (acceptedCandidate.splitOffset != normalizedInitialOffset ||
acceptedCandidate.splitOffset != furthestFittingCandidate.splitOffset
) {
val reason = if (acceptedCandidate.splitOffset != furthestFittingCandidate.splitOffset) {
"best_rendered_last_line"
} else {
"rendered_prefix_fit"
}
logAndroidEpubCutoff(
"cutoff_probe layer=android_justified_split_adjust block=${block.blockIndex} " +
"sourceRange=${block.startCharOffsetInSource}..${block.endCharOffsetInSource} " +
"initialSplitOffset=$initialSplitOffset normalizedInitialOffset=$normalizedInitialOffset " +
"adjustedSplitOffset=${acceptedCandidate.splitOffset} availableTextHeightPx=$availableTextHeight " +
"adjustedPrefixHeightPx=${acceptedCandidate.prefixHeightPx} " +
"adjustedPrefixLines=${acceptedCandidate.prefixLineCount} " +
"adjustedRemainingLines=${acceptedCandidate.remainingLineCount} " +
"adjustedLineChars=${acceptedCandidate.lastLineChars} " +
"adjustedLineWords=${acceptedCandidate.lastLineWords} " +
"adjustedLineGapPx=${acceptedCandidate.lastLineVisualGapPx} " +
"furthestFitSplitOffset=${furthestFittingCandidate.splitOffset} " +
"furthestFitLineWords=${furthestFittingCandidate.lastLineWords} " +
"furthestFitLineGapPx=${furthestFittingCandidate.lastLineVisualGapPx} " +
"reason=$reason"
)
}
return acceptedCandidate.splitOffset
}
private suspend fun splitParagraphBlock(
block: ParagraphBlock,
textMeasurer: TextMeasurer,
@ -956,6 +1451,7 @@ private suspend fun splitParagraphBlock(
availableHeight: Int,
density: Density
): Pair<ParagraphBlock, ParagraphBlock>? {
coroutineContext.ensureActive()
val text = block.content
if (text.isEmpty()) return null
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
@ -992,7 +1488,8 @@ private suspend fun splitParagraphBlock(
)
}
if (layoutResult.size.height <= availableTextHeight) {
coroutineContext.ensureActive()
if (layoutResult.paginationMeasuredHeightPx() <= availableTextHeight) {
return null
}
@ -1010,9 +1507,12 @@ private suspend fun splitParagraphBlock(
return null
}
if (lastVisibleLine == 0) {
val orphanLines = block.style.orphans.coerceAtLeast(1)
val widowLines = block.style.widows.coerceAtLeast(1)
val visibleLineCount = lastVisibleLine + 1
if (visibleLineCount < orphanLines) {
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
Timber.d("Orphan control: Preventing split that would leave $visibleLineCount line(s) at the bottom of the page.")
}
return null
}
@ -1028,15 +1528,42 @@ private suspend fun splitParagraphBlock(
constraints = paragraphConstraints
)
}
if (part2Layout.lineCount == 1) {
coroutineContext.ensureActive()
if (part2Layout.lineCount < widowLines) {
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
Timber.d("Widow control: Adjusting split to keep at least $widowLines line(s) at the top of the next page.")
}
lastVisibleLine--
val linesToMove = widowLines - part2Layout.lineCount
lastVisibleLine -= linesToMove.coerceAtLeast(1)
if (lastVisibleLine + 1 < orphanLines) return null
splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
}
}
splitOffset = adjustJustifiedSplitOffsetForRenderedPrefix(
block = block,
text = text,
textMeasurer = textMeasurer,
paragraphStyle = paragraphStyle,
paragraphConstraints = paragraphConstraints,
initialSplitOffset = splitOffset,
availableTextHeight = availableTextHeight,
orphanLines = orphanLines,
widowLines = widowLines
) ?: return null
logJustifiedSplitGapIfSuspicious(
block = block,
text = text,
textMeasurer = textMeasurer,
paragraphStyle = paragraphStyle,
paragraphConstraints = paragraphConstraints,
layoutResult = layoutResult,
lastVisibleLine = lastVisibleLine,
splitOffset = splitOffset,
availableTextHeight = availableTextHeight
)
if (splitOffset <= 0 || splitOffset >= text.length) {
return null
}
@ -1058,6 +1585,19 @@ private suspend fun splitParagraphBlock(
return null
}
logRenderedJustifiedSplitGapIfSuspicious(
block = block,
part1Text = part1Text,
part2Text = part2Text,
textMeasurer = textMeasurer,
paragraphStyle = paragraphStyle,
paragraphConstraints = paragraphConstraints,
originalLayoutResult = layoutResult,
originalLastVisibleLine = lastVisibleLine,
splitOffset = splitOffset,
availableTextHeight = availableTextHeight
)
val part2TextWithoutIndent = buildAnnotatedString {
append(part2Text)
part2Text.paragraphStyles.firstOrNull { it.start == 0 && it.item.textIndent != null }?.let { styleRange ->
@ -1126,7 +1666,8 @@ private suspend fun calculateContentHeightWithMargins(
imageSizeMultiplier: Float = 1.0f
): Int {
var totalHeight = 0
children.forEachIndexed { index, child ->
for ((index, child) in children.withIndex()) {
coroutineContext.ensureActive()
val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
val margin = with(density) {
if (index > 0) {
@ -1174,6 +1715,7 @@ private fun computeBlockBoxMetrics(
val isBorderBox = block.style.boxSizing == "border-box"
val specifiedWidthDp = block.style.width
val specifiedMaxWidthDp = block.style.maxWidth
val specifiedMinWidthDp = block.style.minWidth
val blockOuterWidthPx = with(density) {
var effectiveWidthPx = constraints.maxWidth.toFloat()
@ -1186,6 +1728,9 @@ private fun computeBlockBoxMetrics(
effectiveWidthPx = maxWidthPx
}
}
if (specifiedMinWidthDp != Dp.Unspecified) {
effectiveWidthPx = effectiveWidthPx.coerceAtLeast(specifiedMinWidthDp.toPx())
}
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
}
@ -1209,7 +1754,7 @@ private fun centeredTextSafetyPaddingPx(
style: TextStyle,
density: Density
): Int {
if (style.textAlign != androidx.compose.ui.text.style.TextAlign.Center) return 0
if (style.textAlign != TextAlign.Center) return 0
val fallbackLineHeight = if (style.fontSize.isSpecified) {
style.fontSize * 1.2f

View file

@ -0,0 +1,247 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
internal const val TAG_PAGINATED_LINK_DIAG = "PaginatedLinkDiag"
private const val LINK_DIAG_MAX_SAMPLES = 4
private val linkDiagWhitespaceRegex = Regex("\\s+")
internal fun Document.readerHtmlLinkDiagSummary(): String {
val linkElements = getElementsByTag("a").mapNotNull { element ->
element.readerHrefForDiagnostics()?.let { href -> element to href }
}
val samples = linkElements.take(LINK_DIAG_MAX_SAMPLES).joinToString(
prefix = "[",
postfix = "]"
) { (element, href) ->
"href=${href.readerLinkDiagPreview()} text=\"${element.text().readerLinkDiagPreview()}\""
}
return "htmlAnchors=${linkElements.size} htmlSamples=$samples"
}
internal fun List<SemanticBlock>.readerSemanticLinkDiagSummary(): String {
val collector = ReaderLinkDiagCollector()
forEach { it.collectSemanticLinks(collector) }
return collector.semanticSummary()
}
internal fun List<ContentBlock>.readerContentLinkDiagSummary(): String {
val collector = ReaderLinkDiagCollector()
forEach { it.collectContentLinks(collector, pageInChapter = null) }
return collector.contentSummary()
}
internal fun Page.readerPageLinkDiagSummary(): String {
val collector = ReaderLinkDiagCollector()
content.forEach { it.collectContentLinks(collector, pageInChapter = null) }
return collector.contentSummary()
}
internal fun List<Page>.readerPagesLinkDiagSummary(): String {
val collector = ReaderLinkDiagCollector()
forEachIndexed { pageInChapter, page ->
page.content.forEach { it.collectContentLinks(collector, pageInChapter) }
}
return "pages=$size ${collector.contentSummary()}"
}
internal fun AnnotatedString.readerAnnotatedLinkDiagSummary(): String {
val collector = ReaderLinkDiagCollector()
collector.addAnnotatedLinks(
blockIndex = null,
blockType = "AnnotatedString",
cfi = null,
text = this,
pageInChapter = null
)
return collector.contentSummary()
}
internal fun String.readerLinkDiagPreview(maxLength: Int = 96): String {
val cleaned = replace(linkDiagWhitespaceRegex, " ").trim()
return if (cleaned.length <= maxLength) cleaned else cleaned.take(maxLength - 3) + "..."
}
private fun Element.readerHrefForDiagnostics(): String? {
return attr("href")
.ifBlank { attr("xlink:href") }
.ifBlank { attr("l:href") }
.ifBlank { attr("epub:href") }
.ifBlank { null }
}
private class ReaderLinkDiagCollector {
private var semanticTextBlocks = 0
private var semanticLinkSpans = 0
private val semanticSamples = mutableListOf<String>()
private var contentTextBlocks = 0
private var contentUrlAnnotations = 0
private var contentLinksWithColor = 0
private var contentLinksWithBackground = 0
private var contentLinksWithUnderline = 0
private var contentLinksWithCoveringStyle = 0
private val contentSamples = mutableListOf<String>()
fun addSemanticTextBlock(block: SemanticTextBlock) {
semanticTextBlocks++
block.spans.forEach { span ->
val href = span.linkHref?.takeIf { it.isNotBlank() } ?: return@forEach
semanticLinkSpans++
if (semanticSamples.size < LINK_DIAG_MAX_SAMPLES) {
val start = span.start.coerceIn(0, block.text.length)
val end = span.end.coerceIn(start, block.text.length)
semanticSamples += buildString {
append("block=")
append(block.blockIndex)
append(" type=")
append(block::class.simpleName ?: "Text")
append(" tag=")
append(span.tag)
append(" range=")
append(start)
append("..")
append(end)
append(" href=")
append(href.readerLinkDiagPreview())
append(" text=\"")
append(block.text.substring(start, end).readerLinkDiagPreview())
append("\"")
}
}
}
}
fun addAnnotatedLinks(
blockIndex: Int?,
blockType: String,
cfi: String?,
text: AnnotatedString,
pageInChapter: Int?
) {
contentTextBlocks++
val annotations = text.getStringAnnotations("URL", 0, text.length)
.filter { it.item.isNotBlank() }
annotations.forEach { annotation ->
contentUrlAnnotations++
val coverage = text.readerLinkStyleCoverage(annotation)
if (coverage.hasColor) contentLinksWithColor++
if (coverage.hasBackground) contentLinksWithBackground++
if (coverage.hasUnderline) contentLinksWithUnderline++
if (coverage.hasCoveringStyle) contentLinksWithCoveringStyle++
if (contentSamples.size < LINK_DIAG_MAX_SAMPLES) {
contentSamples += buildString {
if (pageInChapter != null) {
append("pageInChapter=")
append(pageInChapter)
append(" ")
}
append("block=")
append(blockIndex ?: -1)
append(" type=")
append(blockType)
if (!cfi.isNullOrBlank()) {
append(" cfi=")
append(cfi)
}
append(" range=")
append(annotation.start)
append("..")
append(annotation.end)
append(" href=")
append(annotation.item.readerLinkDiagPreview())
append(" style={color=")
append(coverage.hasColor)
append(",bg=")
append(coverage.hasBackground)
append(",underline=")
append(coverage.hasUnderline)
append(",covering=")
append(coverage.hasCoveringStyle)
append("} text=\"")
append(
text.text.substring(
annotation.start.coerceIn(0, text.length),
annotation.end.coerceIn(annotation.start.coerceIn(0, text.length), text.length)
).readerLinkDiagPreview()
)
append("\"")
}
}
}
}
fun semanticSummary(): String {
return "semanticTextBlocks=$semanticTextBlocks semanticLinkSpans=$semanticLinkSpans semanticSamples=${semanticSamples.joinToString(prefix = "[", postfix = "]")}"
}
fun contentSummary(): String {
return "contentTextBlocks=$contentTextBlocks urlAnnotations=$contentUrlAnnotations styled={color=$contentLinksWithColor,bg=$contentLinksWithBackground,underline=$contentLinksWithUnderline,covering=$contentLinksWithCoveringStyle} contentSamples=${contentSamples.joinToString(prefix = "[", postfix = "]")}"
}
}
private data class ReaderLinkStyleCoverage(
val hasColor: Boolean,
val hasBackground: Boolean,
val hasUnderline: Boolean,
val hasCoveringStyle: Boolean
)
private fun AnnotatedString.readerLinkStyleCoverage(
link: AnnotatedString.Range<String>
): ReaderLinkStyleCoverage {
val overlappingStyles = spanStyles.filter { styleRange ->
styleRange.start < link.end && styleRange.end > link.start
}
return ReaderLinkStyleCoverage(
hasColor = overlappingStyles.any { it.item.color.isSpecified },
hasBackground = overlappingStyles.any { it.item.background.isSpecified },
hasUnderline = overlappingStyles.any {
it.item.textDecoration?.contains(TextDecoration.Underline) == true
},
hasCoveringStyle = overlappingStyles.any {
it.start <= link.start && it.end >= link.end
}
)
}
private fun SemanticBlock.collectSemanticLinks(collector: ReaderLinkDiagCollector) {
when (this) {
is SemanticList -> items.forEach { it.collectSemanticLinks(collector) }
is SemanticTable -> rows.flatten().forEach { cell ->
cell.content.forEach { it.collectSemanticLinks(collector) }
}
is SemanticFlexContainer -> children.forEach { it.collectSemanticLinks(collector) }
is SemanticWrappingBlock -> paragraphsToWrap.forEach { it.collectSemanticLinks(collector) }
is SemanticTextBlock -> collector.addSemanticTextBlock(this)
else -> Unit
}
}
private fun ContentBlock.collectContentLinks(
collector: ReaderLinkDiagCollector,
pageInChapter: Int?
) {
when (this) {
is WrappingContentBlock -> paragraphsToWrap.forEach {
it.collectContentLinks(collector, pageInChapter)
}
is TableBlock -> rows.flatten().forEach { cell ->
cell.content.forEach { it.collectContentLinks(collector, pageInChapter) }
}
is FlexContainerBlock -> children.forEach { it.collectContentLinks(collector, pageInChapter) }
is TextContentBlock -> collector.addAnnotatedLinks(
blockIndex = blockIndex,
blockType = this::class.simpleName ?: "Text",
cfi = cfi,
text = content,
pageInChapter = pageInChapter
)
else -> Unit
}
}

View file

@ -0,0 +1,112 @@
package com.aryan.reader.paginatedreader
import com.aryan.reader.SearchResult
internal fun flattenTextContentBlocksForNavigation(blocks: List<ContentBlock>): List<TextContentBlock> {
return blocks.flatMap { block ->
when (block) {
is WrappingContentBlock -> flattenTextContentBlocksForNavigation(
listOf<ContentBlock>(block.floatedImage) + block.paragraphsToWrap
)
is FlexContainerBlock -> flattenTextContentBlocksForNavigation(block.children)
is TableBlock -> block.rows.flatten().flatMap { flattenTextContentBlocksForNavigation(it.content) }
is TextContentBlock -> listOf(block)
else -> emptyList()
}
}
}
internal fun findLocatorForSearchResultInBlocks(
result: SearchResult,
blocks: List<ContentBlock>
): Locator? {
val query = result.query.takeIf { it.isNotBlank() } ?: return null
var occurrenceCount = 0
flattenTextContentBlocksForNavigation(blocks).forEach { block ->
val text = block.content.text
var lastIndex = -1
while (true) {
lastIndex = text.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
val isWordStart = lastIndex == 0 || !text[lastIndex - 1].isLetterOrDigit()
if (isWordStart) {
if (occurrenceCount == result.occurrenceIndexInLocation) {
return Locator(
chapterIndex = result.locationInSource,
blockIndex = block.blockIndex,
charOffset = block.startCharOffsetInSource + lastIndex
)
}
occurrenceCount++
}
}
}
return null
}
internal fun findLocatorForAnchorInBlocks(
chapterIndex: Int,
anchor: String?,
blocks: List<ContentBlock>
): Locator? {
if (anchor.isNullOrBlank()) return Locator(chapterIndex, 0, 0)
return blocks.asSequence()
.mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) }
.firstOrNull()
}
private fun findLocatorForAnchorInBlock(
chapterIndex: Int,
anchor: String,
block: ContentBlock
): Locator? {
if (block.elementId == anchor) return locatorForBlockStart(chapterIndex, block)
if (block is TextContentBlock) {
block.content.getStringAnnotations("ID", 0, block.content.length)
.firstOrNull { it.item == anchor }
?.let { annotation ->
return Locator(
chapterIndex = chapterIndex,
blockIndex = block.blockIndex,
charOffset = block.startCharOffsetInSource + annotation.start
)
}
}
return when (block) {
is FlexContainerBlock -> block.children.asSequence()
.mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) }
.firstOrNull()
is TableBlock -> block.rows.asSequence()
.flatMap { row -> row.asSequence() }
.flatMap { cell -> cell.content.asSequence() }
.mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) }
.firstOrNull()
is WrappingContentBlock -> sequenceOf<ContentBlock>(block.floatedImage)
.plus(block.paragraphsToWrap.asSequence().map { it as ContentBlock })
.mapNotNull { findLocatorForAnchorInBlock(chapterIndex, anchor, it) }
.firstOrNull()
else -> null
}
}
private fun locatorForBlockStart(chapterIndex: Int, block: ContentBlock): Locator {
val firstText = flattenTextContentBlocksForNavigation(listOf(block)).firstOrNull()
return if (firstText != null) {
Locator(
chapterIndex = chapterIndex,
blockIndex = firstText.blockIndex,
charOffset = firstText.startCharOffsetInSource
)
} else {
Locator(
chapterIndex = chapterIndex,
blockIndex = block.blockIndex,
charOffset = 0
)
}
}

View file

@ -50,11 +50,14 @@ abstract class BookCacheDao {
// --- Chapter Operations (Internal Raw Access) ---
@Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex")
protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata?
@Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash")
protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int, styleConfigHash: Int): ProcessedChapterMetadata?
@Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY chunk_index ASC")
protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List<ByteArray>
@Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY rowid DESC LIMIT 1")
protected abstract suspend fun getAnyChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata?
@Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash ORDER BY chunk_index ASC")
protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int, styleConfigHash: Int): List<ByteArray>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata)
@ -65,6 +68,9 @@ abstract class BookCacheDao {
@Query("DELETE FROM processed_chapter_metadata WHERE book_id = :bookId")
protected abstract suspend fun deleteChapterMetadataForBook(bookId: String)
@Query("DELETE FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex AND style_config_hash = :styleConfigHash")
protected abstract suspend fun deleteChapterChunksForChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertAnchorIndices(anchors: List<AnchorIndexEntry>)
@ -75,12 +81,16 @@ abstract class BookCacheDao {
abstract suspend fun deleteAnchorsForBook(bookId: String)
@Transaction
open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? {
val metadata = getChapterMetadata(bookId, chapterIndex) ?: return null
val chunks = getChapterChunks(bookId, chapterIndex)
open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int? = null): ProcessedChapter? {
val metadata = if (styleConfigHash == null) {
getAnyChapterMetadata(bookId, chapterIndex)
} else {
getChapterMetadata(bookId, chapterIndex, styleConfigHash)
} ?: return null
val chunks = getChapterChunks(bookId, chapterIndex, metadata.styleConfigHash)
if (chunks.isEmpty()) {
return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount)
return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount, metadata.styleConfigHash)
}
val totalSize = chunks.sumOf { it.size }
@ -95,7 +105,8 @@ abstract class BookCacheDao {
bookId = bookId,
chapterIndex = chapterIndex,
contentBlocksProto = mergedData,
estimatedPageCount = metadata.estimatedPageCount
estimatedPageCount = metadata.estimatedPageCount,
styleConfigHash = metadata.styleConfigHash
)
}
@ -107,8 +118,10 @@ abstract class BookCacheDao {
val metadata = ProcessedChapterMetadata(
bookId = chapter.bookId,
chapterIndex = chapter.chapterIndex,
estimatedPageCount = chapter.estimatedPageCount
estimatedPageCount = chapter.estimatedPageCount,
styleConfigHash = chapter.styleConfigHash
)
deleteChapterChunksForChapter(chapter.bookId, chapter.chapterIndex, chapter.styleConfigHash)
insertChapterMetadata(metadata)
val fullData = chapter.contentBlocksProto
@ -126,6 +139,7 @@ abstract class BookCacheDao {
ProcessedChapterChunk(
bookId = chapter.bookId,
chapterIndex = chapter.chapterIndex,
styleConfigHash = chapter.styleConfigHash,
chunkIndex = chunkIndex,
chunkData = chunkBytes
)
@ -309,7 +323,7 @@ abstract class BookCacheDao {
PageCacheChunk::class,
PageIndexEntry::class
],
version = 11,
version = 12,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {
@ -326,7 +340,7 @@ abstract class BookCacheDatabase : RoomDatabase() {
BookCacheDatabase::class.java,
"book_cache_database"
)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(true)
.build()
INSTANCE = instance
@ -394,5 +408,65 @@ abstract class BookCacheDatabase : RoomDatabase() {
)
}
}
private val MIGRATION_11_12 = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `processed_chapter_chunks` RENAME TO `processed_chapter_chunks_old`"
)
db.execSQL(
"ALTER TABLE `processed_chapter_metadata` RENAME TO `processed_chapter_metadata_old`"
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `processed_chapter_metadata` (
`book_id` TEXT NOT NULL,
`chapter_index` INTEGER NOT NULL,
`estimated_page_count` INTEGER NOT NULL,
`style_config_hash` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(`book_id`, `chapter_index`, `style_config_hash`)
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO `processed_chapter_metadata` (`book_id`, `chapter_index`, `estimated_page_count`, `style_config_hash`)
SELECT `book_id`, `chapter_index`, `estimated_page_count`, 0
FROM `processed_chapter_metadata_old`
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `processed_chapter_chunks` (
`book_id` TEXT NOT NULL,
`chapter_index` INTEGER NOT NULL,
`style_config_hash` INTEGER NOT NULL DEFAULT 0,
`chunk_index` INTEGER NOT NULL,
`chunk_data` BLOB NOT NULL,
PRIMARY KEY(`book_id`, `chapter_index`, `style_config_hash`, `chunk_index`),
FOREIGN KEY(`book_id`, `chapter_index`, `style_config_hash`)
REFERENCES `processed_chapter_metadata`(`book_id`, `chapter_index`, `style_config_hash`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO `processed_chapter_chunks` (`book_id`, `chapter_index`, `style_config_hash`, `chunk_index`, `chunk_data`)
SELECT `book_id`, `chapter_index`, 0, `chunk_index`, `chunk_data`
FROM `processed_chapter_chunks_old`
""".trimIndent()
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_processed_chapter_chunks_book_id_chapter_index_style_config_hash` ON `processed_chapter_chunks` (`book_id`, `chapter_index`, `style_config_hash`)"
)
db.execSQL(
"DROP TABLE `processed_chapter_chunks_old`"
)
db.execSQL(
"DROP TABLE `processed_chapter_metadata_old`"
)
}
}
}
}

View file

@ -25,8 +25,8 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 11
const val LATEST_PAGE_CACHE_VERSION = 3
const val LATEST_PROCESSING_VERSION = 15
const val LATEST_PAGE_CACHE_VERSION = 4
@Entity(tableName = "processed_books")
data class ProcessedBook(
@ -52,7 +52,8 @@ data class ProcessedChapter(
val bookId: String,
val chapterIndex: Int,
val contentBlocksProto: ByteArray,
val estimatedPageCount: Int
val estimatedPageCount: Int,
val styleConfigHash: Int = 0
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@ -62,6 +63,7 @@ data class ProcessedChapter(
if (chapterIndex != other.chapterIndex) return false
if (!contentBlocksProto.contentEquals(other.contentBlocksProto)) return false
if (estimatedPageCount != other.estimatedPageCount) return false
if (styleConfigHash != other.styleConfigHash) return false
return true
}
@ -70,6 +72,7 @@ data class ProcessedChapter(
result = 31 * result + chapterIndex
result = 31 * result + contentBlocksProto.contentHashCode()
result = 31 * result + estimatedPageCount
result = 31 * result + styleConfigHash
return result
}
}
@ -77,11 +80,12 @@ data class ProcessedChapter(
/**
* Database Entity: Stores metadata only (small size).
*/
@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index"])
@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index", "style_config_hash"])
data class ProcessedChapterMetadata(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int
@ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int,
@ColumnInfo(name = "style_config_hash") val styleConfigHash: Int = 0
)
/**
@ -89,20 +93,21 @@ data class ProcessedChapterMetadata(
*/
@Entity(
tableName = "processed_chapter_chunks",
primaryKeys = ["book_id", "chapter_index", "chunk_index"],
primaryKeys = ["book_id", "chapter_index", "style_config_hash", "chunk_index"],
foreignKeys = [
ForeignKey(
entity = ProcessedChapterMetadata::class,
parentColumns = ["book_id", "chapter_index"],
childColumns = ["book_id", "chapter_index"],
parentColumns = ["book_id", "chapter_index", "style_config_hash"],
childColumns = ["book_id", "chapter_index", "style_config_hash"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "chapter_index"])]
indices = [Index(value = ["book_id", "chapter_index", "style_config_hash"])]
)
data class ProcessedChapterChunk(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "style_config_hash") val styleConfigHash: Int = 0,
@ColumnInfo(name = "chunk_index") val chunkIndex: Int,
@ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray
) {
@ -112,6 +117,7 @@ data class ProcessedChapterChunk(
other as ProcessedChapterChunk
if (bookId != other.bookId) return false
if (chapterIndex != other.chapterIndex) return false
if (styleConfigHash != other.styleConfigHash) return false
if (chunkIndex != other.chunkIndex) return false
if (!chunkData.contentEquals(other.chunkData)) return false
return true
@ -120,6 +126,7 @@ data class ProcessedChapterChunk(
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + chapterIndex
result = 31 * result + styleConfigHash
result = 31 * result + chunkIndex
result = 31 * result + chunkData.contentHashCode()
return result

View file

@ -31,11 +31,14 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aryan.reader.applyBookReplacementsToHtmlDocument
import com.aryan.reader.epub.epubContentFilePath
import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver
import com.aryan.reader.paginatedreader.FontFaceInfo
import com.aryan.reader.paginatedreader.MathMLRenderer
import com.aryan.reader.paginatedreader.OptimizedCssRules
@ -43,9 +46,12 @@ import com.aryan.reader.paginatedreader.RenderResult
import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks
import com.aryan.reader.paginatedreader.loadFontFamilies
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@ -56,8 +62,8 @@ import kotlinx.serialization.protobuf.ProtoNumber
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.io.File
import java.net.URLDecoder
import kotlin.math.abs
import kotlin.coroutines.coroutineContext
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@ -78,7 +84,10 @@ data class BookProcessingInput(
@ProtoNumber(5) val density: Float,
@ProtoNumber(6) val constraintsMaxWidth: Int,
@ProtoNumber(7) val constraintsMaxHeight: Int,
@ProtoNumber(8) val fontFaces: List<FontFaceInfo> = emptyList()
@ProtoNumber(8) val fontFaces: List<FontFaceInfo> = emptyList(),
@ProtoNumber(9) val styleConfigHash: Int = 0,
@ProtoNumber(10) val bookReplacementPreferencesJson: String = "",
@ProtoNumber(11) val bookReplacementFileId: String = ""
)
@OptIn(ExperimentalSerializationApi::class)
@ -95,6 +104,13 @@ class BookProcessingWorker(
private const val KEY_ESTIMATED_TOTAL_PAGES = "estimatedTotalPages"
private const val KEY_START_CHAPTER_INDEX = "startChapterIndex"
private fun uniqueWorkName(bookId: String): String = "process_$bookId"
fun cancelForBook(context: Context, bookId: String) {
WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName(bookId))
Timber.i("Cancelled stale background processing for book: $bookId")
}
fun enqueue(
context: Context,
bookId: String,
@ -121,11 +137,11 @@ class BookProcessingWorker(
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"process_$bookId",
androidx.work.ExistingWorkPolicy.KEEP,
uniqueWorkName(bookId),
ExistingWorkPolicy.REPLACE,
workRequest
)
Timber.i("Enqueued background processing for book: $bookId")
Timber.i("Enqueued latest background processing for book: $bookId config=${processingInput.styleConfigHash}")
}
}
@ -137,7 +153,6 @@ class BookProcessingWorker(
Timber.i("Starting pre-scan to calculate image dimensions...")
for (chapter in chapters) {
val document = Jsoup.parse(chapter.htmlContent)
val chapterParentPath = File(chapter.absPath).parent ?: ""
// Find all image tags (both <img> and <svg><image>)
document.select("img, image").forEach { element ->
@ -145,14 +160,10 @@ class BookProcessingWorker(
val src = element.attr(srcAttr).ifBlank { element.attr("xlink:href") }
if (src.isNotBlank()) {
val decodedSrc = try {
URLDecoder.decode(src, "UTF-8")
} catch (_: Exception) {
src
}
val imageFile = File(File(extractionBasePath, chapterParentPath), decodedSrc).canonicalFile
val imagePath = imageFile.absolutePath
val imagePath = AndroidHtmlResourceResolver
.resolvePath(chapter.absPath, extractionBasePath, src)
?: return@forEach
val imageFile = File(imagePath)
// If not already cached, read dimensions from disk
if (imageFile.exists() && !dimensionsCache.containsKey(imagePath)) {
@ -196,6 +207,9 @@ class BookProcessingWorker(
return@withContext Result.failure()
}
val input = proto.decodeFromByteArray<BookProcessingInput>(inputFile.readBytes())
val bookReplacementPreferences = ReaderBookReplacementPreferencesJson.decodeOrEmpty(
input.bookReplacementPreferencesJson,
)
Timber.i("Worker decoded input. Number of chapters received: ${input.chapters.size}")
// Worker now reconstructs everything it needs for a pure light-theme processing run.
@ -240,11 +254,13 @@ class BookProcessingWorker(
Timber.i("Worker processing with up to $numCores threads, prioritizing around chapter $startChapterIndex.")
chaptersToProcess.chunked(numCores).forEach { chunk ->
coroutineContext.ensureActive()
Timber.d("Processing a chunk of ${chunk.size} chapters.")
val deferreds = chunk.map { (index, chapter) ->
async {
coroutineContext.ensureActive()
Timber.d("Async task started for chapter index $index.")
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
if (db.bookCacheDao().getProcessedChapter(bookId, index, input.styleConfigHash) == null) {
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
val htmlToParse = chapter.htmlContent.ifBlank {
val backingFile = File(extractionBasePath, epubContentFilePath(chapter.htmlFilePath))
@ -290,6 +306,12 @@ class BookProcessingWorker(
}
Timber.d("Chapter $index (Background Worker): Finished processing MathML. SVG cache has ${svgResults.size} items. Keys: ${svgResults.keys.joinToString()}")
}
applyBookReplacementsToHtmlDocument(
document = document,
preferences = bookReplacementPreferences,
fileId = input.bookReplacementFileId,
)
coroutineContext.ensureActive()
val processedHtml = document.outerHtml()
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
@ -306,12 +328,14 @@ class BookProcessingWorker(
imageDimensionsCache = imageDimensionsCache,
mathSvgCache = svgResults
)
coroutineContext.ensureActive()
val protoBytes = proto.encodeToByteArray(semanticBlocks)
ProcessedChapter(
bookId = bookId,
chapterIndex = index,
contentBlocksProto = protoBytes,
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
estimatedPageCount = estimateSemanticPageCount(semanticBlocks),
styleConfigHash = input.styleConfigHash
)
} else {
Timber.d("Chapter $index was already in the database. Skipping.")
@ -341,6 +365,9 @@ class BookProcessingWorker(
Timber.i("[BG_PROC] Finished processing all chapters for book $bookId.")
return@withContext Result.success()
} catch (e: CancellationException) {
Timber.i("Background processing cancelled for book $bookId")
throw e
} catch (e: Exception) {
Timber.e(e, "Error in pagination worker for book $bookId")
return@withContext Result.failure()

View file

@ -96,7 +96,11 @@ import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrLine
import com.aryan.reader.pdf.ocr.OcrResult
import com.aryan.reader.pdf.ocr.OcrSymbol
import com.aryan.reader.shared.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
import com.aryan.reader.shared.pdf.pdfCommentChildren
import com.aryan.reader.shared.pdf.visiblePdfAnnotationComments
import com.aryan.reader.shared.pdf.withoutPdfCommentThread
import timber.log.Timber
import java.text.DateFormat
import java.util.Date
@ -708,8 +712,6 @@ private enum class PdfAnnotationSheetSection {
COMMENTS
}
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PdfAnnotationBottomSheet(
@ -740,7 +742,7 @@ fun PdfAnnotationBottomSheet(
highlight.comments
.lastOrNull { it.author.isNotBlank() }
?.author
?: DEFAULT_PDF_COMMENT_AUTHOR
?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR
)
}
@ -844,14 +846,14 @@ fun PdfAnnotationBottomSheet(
editingCommentId = comment.id
replyTargetId = null
commentText = comment.contents
commentAuthor = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
},
onCancelEdit = {
editingCommentId = null
commentText = ""
},
onDelete = { comment ->
val nextComments = comments.withoutCommentThread(comment.id)
val nextComments = comments.withoutPdfCommentThread(comment.id)
persistComments(nextComments)
if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) {
replyTargetId = null
@ -865,7 +867,7 @@ fun PdfAnnotationBottomSheet(
val contents = commentText.trim()
if (contents.isNotBlank()) {
val now = System.currentTimeMillis()
val author = commentAuthor.trim().ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
val nextComments = if (editingCommentId != null) {
comments.map { comment ->
if (comment.id == editingCommentId) {
@ -1005,16 +1007,7 @@ private fun PdfHighlightCommentsEditor(
onDelete: (SharedPdfAnnotationComment) -> Unit,
onAddComment: () -> Unit
) {
val commentIds = comments.filter { it.contents.isNotBlank() }.map { it.id }.toSet()
val visibleComments = comments
.filter { it.contents.isNotBlank() }
.map { comment ->
if (comment.parentId != null && comment.parentId !in commentIds) {
comment.copy(parentId = null)
} else {
comment
}
}
val visibleComments = comments.visiblePdfAnnotationComments()
val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId }
val editingComment = visibleComments.firstOrNull { it.id == editingCommentId }
@ -1048,7 +1041,7 @@ private fun PdfHighlightCommentsEditor(
} else {
stringResource(
R.string.label_replying_to,
replyTarget?.author?.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }.orEmpty()
replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty()
)
},
style = MaterialTheme.typography.labelMedium,
@ -1115,8 +1108,7 @@ private fun PdfHighlightCommentThread(
onDelete: (SharedPdfAnnotationComment) -> Unit
) {
comments
.filter { it.parentId == parentId }
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
.pdfCommentChildren(parentId)
.forEach { comment ->
if (comment.id in visitedIds) return@forEach
PdfHighlightCommentItem(
@ -1167,7 +1159,7 @@ private fun PdfHighlightCommentItem(
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR },
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
@ -1216,19 +1208,6 @@ private fun pdfAnnotationTextFieldColors(effectiveText: Color) =
unfocusedTextColor = effectiveText
)
private fun List<SharedPdfAnnotationComment>.withoutCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
val childrenByParentId = groupBy { it.parentId }
val idsToRemove = mutableSetOf<String>()
fun collect(id: String) {
if (!idsToRemove.add(id)) return
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
}
collect(commentId)
return filterNot { it.id in idsToRemove }
}
private fun Long.formatPdfCommentTimestamp(): String {
if (this <= 0L) return ""
return runCatching {

View file

@ -1,13 +1,10 @@
package com.aryan.reader.pdf
import android.graphics.Bitmap
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
@ -21,7 +18,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -38,10 +34,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -195,42 +188,6 @@ internal fun PageScrubbingAnimation(
}
}
@Composable
internal fun ThumbnailWithIndicator(
thumbnail: Bitmap,
modifier: Modifier = Modifier,
borderColor: Color = Color.Unspecified,
onClick: () -> Unit
) {
val effectiveBorderColor = if (borderColor == Color.Unspecified) {
MaterialTheme.colorScheme.primary
} else {
borderColor
}
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Surface(
modifier = Modifier
.width(45.dp)
.height(64.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(4.dp),
border = BorderStroke(2.dp, effectiveBorderColor)
) {
Image(
bitmap = thumbnail.asImageBitmap(),
contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
}
Box(modifier = Modifier
.offset(y = (-4).dp)
.size(8.dp)
.rotate(45f)
.background(effectiveBorderColor))
}
}
@Composable
internal fun BookmarkButton(
isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier

View file

@ -5713,10 +5713,11 @@ fun PdfRichTextLayer(
val selection = tfv.selection
@Suppress("ControlFlowWithEmptyBody") if (controller.activePageIndex == pageIndex) {
val localStart = selection.start.coerceIn(0, textToRender.length)
val localEnd = selection.end.coerceIn(0, textToRender.length)
if (localStart != localEnd) {
androidPdfRichTextSelectionBounds(
selectionStart = selection.start,
selectionEnd = selection.end,
textLength = textToRender.length
)?.let { (localStart, localEnd) ->
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
Canvas(modifier = Modifier.fillMaxSize()) {
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
@ -5724,6 +5725,7 @@ fun PdfRichTextLayer(
}
if (selection.collapsed && controller.isCursorVisible) {
val localStart = selection.start.coerceIn(0, textToRender.length)
val alpha = if (isScrolling) {
1f
} else {

View file

@ -115,12 +115,48 @@ private fun sanitizePdfToolNameSet(
}.toSet()
}
internal fun sanitizePdfHiddenToolNames(toolNames: Collection<String>): Set<String> {
return sanitizePdfToolNameSet(toolNames.toSet())
}
internal fun sanitizePdfBottomToolNames(toolNames: Collection<String>): Set<String> {
return sanitizePdfToolNameSet(
toolNames = toolNames.toSet(),
includeTool = ::isPdfToolbarPlacementTool
)
}
internal fun restorePdfToolOrderNames(toolNames: Collection<String>): List<PdfReaderTool> {
val savedTools = toolNames
.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
.filter(::isPdfReaderToolAvailable)
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
}
internal fun isPdfToolbarPlacementTool(tool: PdfReaderTool): Boolean {
return when (tool) {
PdfReaderTool.DICTIONARY,
PdfReaderTool.THEME,
PdfReaderTool.BRIGHTNESS,
PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER,
PdfReaderTool.TOC,
PdfReaderTool.SEARCH,
PdfReaderTool.HIGHLIGHT_ALL,
PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE,
PdfReaderTool.TTS_CONTROLS,
PdfReaderTool.SCREEN_ORIENTATION -> true
else -> false
}
}
internal fun loadPdfHiddenTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedHiddenTools = sanitizePdfToolNameSet(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty())
val savedHiddenTools = sanitizePdfHiddenToolNames(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty())
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
val migratedHiddenTools = sanitizePdfToolNameSet(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion))
val migratedHiddenTools = sanitizePdfHiddenToolNames(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion))
prefs.edit {
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
@ -143,28 +179,27 @@ private fun pdfHiddenToolsIntroducedAfter(defaultsVersion: Int): Set<String> {
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfToolNameSet(hiddenTools))
putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfHiddenToolNames(hiddenTools))
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
}
}
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedTools = prefs.getString(PDF_TOOL_ORDER_KEY, null)
val savedToolNames = prefs.getString(PDF_TOOL_ORDER_KEY, null)
?.split(',')
?.filter { it.isNotBlank() }
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
?.filter(::isPdfReaderToolAvailable)
.orEmpty()
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
return restorePdfToolOrderNames(savedToolNames)
}
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val sanitizedOrder = restorePdfToolOrderNames(toolOrder.map { it.name })
prefs.edit {
putString(
PDF_TOOL_ORDER_KEY,
toolOrder.filter(::isPdfReaderToolAvailable).joinToString(",") { it.name }
sanitizedOrder.joinToString(",") { it.name }
)
}
}
@ -172,22 +207,15 @@ internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>)
internal fun loadPdfBottomTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val defaultBottomTools = defaultPdfBottomTools()
return sanitizePdfToolNameSet(
toolNames = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools,
includeTool = { it.category == "Bottom Bar" }
)
val savedBottomTools = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, null) ?: return defaultBottomTools
val sanitizedBottomTools = sanitizePdfBottomToolNames(savedBottomTools)
return if (savedBottomTools.isNotEmpty() && sanitizedBottomTools.isEmpty()) defaultBottomTools else sanitizedBottomTools
}
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putStringSet(
PDF_BOTTOM_TOOLS_KEY,
sanitizePdfToolNameSet(
toolNames = bottomTools,
includeTool = { it.category == "Bottom Bar" }
)
)
putStringSet(PDF_BOTTOM_TOOLS_KEY, sanitizePdfBottomToolNames(bottomTools))
}
}

View file

@ -116,25 +116,17 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
return result
}
private val pdfReorderableToolbarTools = setOf(
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
PdfReaderTool.SCREEN_ORIENTATION
)
internal fun buildPdfToolbarItems(
hiddenTools: Set<String>,
toolOrder: List<PdfReaderTool>,
bottomTools: Set<String>
): List<PdfFlatToolItem> {
val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable)
val toolbarTools = availableToolOrder.filter { it in pdfReorderableToolbarTools }
val toolbarTools = availableToolOrder.filter(::isPdfToolbarPlacementTool)
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 = availableToolOrder.filter { it !in pdfReorderableToolbarTools }
val moreTools = availableToolOrder.filterNot(::isPdfToolbarPlacementTool)
val list = mutableListOf<PdfFlatToolItem>()
@ -209,7 +201,7 @@ fun PdfCustomizeToolsSheet(
val commitDragDrop = {
val newHidden = localHiddenTools.filter { toolName ->
toolOrder.find { it.name == toolName } !in pdfReorderableToolbarTools
toolOrder.find { it.name == toolName }?.let(::isPdfToolbarPlacementTool) != true
}.toMutableSet()
val newBottom = mutableSetOf<String>()

View file

@ -50,21 +50,6 @@ import kotlin.collections.isNotEmpty
internal val PdfTabStripHeight = 44.dp
private val pdfToolbarTools = setOf(
PdfReaderTool.DICTIONARY,
PdfReaderTool.THEME,
PdfReaderTool.BRIGHTNESS,
PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER,
PdfReaderTool.TOC,
PdfReaderTool.SEARCH,
PdfReaderTool.HIGHLIGHT_ALL,
PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE,
PdfReaderTool.TTS_CONTROLS,
PdfReaderTool.SCREEN_ORIENTATION
)
internal enum class PdfOverflowMenuSection {
CUSTOMIZE_TOOLBAR,
HIDDEN_TOOLS,
@ -243,6 +228,8 @@ internal fun PdfTopBar(
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
else -> stringResource(R.string.pdf_viewer)
}
val topToolbarTools = toolOrder
.filter { isPdfToolbarPlacementTool(it) && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
Text(
text = titleText,
style = MaterialTheme.typography.titleMedium,
@ -251,117 +238,126 @@ internal fun PdfTopBar(
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
)
toolOrder
.filter { it in pdfToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
.forEach { tool ->
when (tool) {
PdfReaderTool.THEME -> TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onShowThemePanel
) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
text = stringResource(R.string.reader_brightness_title),
description = stringResource(R.string.reader_brightness_system_desc),
onClick = onShowBrightnessControl
) {
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
onClick = onToggleScrollLock
) {
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.DICTIONARY -> TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onShowDictionarySettings
) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.SLIDER -> TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading
) {
Icon(
painterResource(id = R.drawable.slider),
contentDescription = stringResource(R.string.content_desc_navigate_slider),
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
PdfReaderTool.TOC -> TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onShowToc,
enabled = !isTtsPlayingOrLoading
) {
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
}
PdfReaderTool.SEARCH -> TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onSearchClick,
enabled = !isTtsPlayingOrLoading
) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
onClick = onToggleHighlights
) {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = onShowAiHub
if (topToolbarTools.isNotEmpty() || BuildConfig.DEBUG) {
val topToolbarScrollState = rememberScrollState()
Row(
modifier = Modifier
.weight(1f, fill = false)
.horizontalScroll(topToolbarScrollState),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
topToolbarTools.forEach { tool ->
when (tool) {
PdfReaderTool.THEME -> TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onShowThemePanel
) {
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
text = stringResource(R.string.reader_brightness_title),
description = stringResource(R.string.reader_brightness_system_desc),
onClick = onShowBrightnessControl
) {
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
onClick = onToggleScrollLock
) {
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.DICTIONARY -> TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onShowDictionarySettings
) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.SLIDER -> TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading
) {
Icon(
painterResource(id = R.drawable.slider),
contentDescription = stringResource(R.string.content_desc_navigate_slider),
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
PdfReaderTool.TOC -> TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onShowToc,
enabled = !isTtsPlayingOrLoading
) {
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
}
PdfReaderTool.SEARCH -> TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onSearchClick,
enabled = !isTtsPlayingOrLoading
) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
onClick = onToggleHighlights
) {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = onShowAiHub
) {
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
}
}
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
onClick = onToggleEditMode
) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
text = stringResource(R.string.menu_screen_orientation),
description = stringResource(R.string.visual_options_screen_orientation_desc),
onClick = onShowScreenOrientation
) {
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> Unit
}
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
onClick = onToggleEditMode
) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
text = stringResource(R.string.menu_screen_orientation),
description = stringResource(R.string.visual_options_screen_orientation_desc),
onClick = onShowScreenOrientation
) {
Icon(Icons.Default.ScreenRotation, contentDescription = stringResource(R.string.menu_screen_orientation), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> Unit
}
}
if (BuildConfig.DEBUG) {
TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) {
Icon(Icons.Default.BugReport, contentDescription = stringResource(R.string.content_desc_generate_demo_annotations), tint = MaterialTheme.colorScheme.secondary)
}
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
Icon(Icons.Default.Star, contentDescription = stringResource(R.string.content_desc_open_pen_playground), tint = MaterialTheme.colorScheme.primary)
}
TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) {
Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63))
if (BuildConfig.DEBUG) {
TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) {
Icon(Icons.Default.BugReport, contentDescription = stringResource(R.string.content_desc_generate_demo_annotations), tint = MaterialTheme.colorScheme.secondary)
}
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
Icon(Icons.Default.Star, contentDescription = stringResource(R.string.content_desc_open_pen_playground), tint = MaterialTheme.colorScheme.primary)
}
TooltipIconButton(text = stringResource(R.string.import_svg), onClick = onImportSvg) {
Icon(Icons.Default.Brush, contentDescription = stringResource(R.string.import_svg), tint = Color(0xFFE91E63))
}
}
}
}
@ -394,7 +390,7 @@ internal fun PdfTopBar(
showMoreMenu = false
}
) {
val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
val hiddenToolbarTools = toolOrder.filter { isPdfToolbarPlacementTool(it) && hiddenTools.contains(it.name) }
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
@ -970,7 +966,7 @@ fun PdfBottomBar(
horizontalArrangement = Arrangement.SpaceEvenly
) {
toolOrder
.filter { it in pdfToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
.filter { isPdfToolbarPlacementTool(it) && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
.forEach { tool ->
when (tool) {
PdfReaderTool.THEME -> TooltipIconButton(

View file

@ -95,6 +95,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
@ -114,7 +116,6 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -213,6 +214,7 @@ import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.AiFeature
import com.aryan.reader.AiHubBottomSheet
import com.aryan.reader.BuildConfig
import com.aryan.reader.COMIC_ARCHIVE_FILE_TYPES
import com.aryan.reader.FileType
import com.aryan.reader.HighlightColorPickerDialog
import com.aryan.reader.MainViewModel
@ -246,6 +248,7 @@ import com.aryan.reader.loadReaderBrightnessSettings
import com.aryan.reader.loadReaderScreenOrientationMode
import com.aryan.reader.loadReaderSliderToggled
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.logCloudAnnotationSyncTrace
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
@ -260,6 +263,7 @@ import com.aryan.reader.pdf.data.TextStyleConfig
import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.readerSliderBookmarkPosition
import com.aryan.reader.readerSliderChromeColors
import com.aryan.reader.readerSliderStepPage
import com.aryan.reader.readerSliderToggleState
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
@ -273,11 +277,16 @@ import com.aryan.reader.scaledToCanvasLimit
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.pdf.PdfSpreadLayout
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.ui.ReaderMinimalSlider
import com.aryan.reader.shouldRenderReaderSlider
import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.ReaderTtsOverlaySize
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadReaderTtsOverlaySize
import com.aryan.reader.tts.readerTtsOverlayAlignmentBias
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.saveReaderTtsOverlaySize
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
@ -387,23 +396,41 @@ fun PdfViewerScreen(
var pendingActionAfterOcrSelection by remember { mutableStateOf<(() -> Unit)?>(null) }
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
var hiddenTools by remember { mutableStateOf(loadPdfHiddenTools(context)) }
var toolOrder by remember { mutableStateOf(loadPdfToolOrder(context)) }
var bottomTools by remember { mutableStateOf(loadPdfBottomTools(context)) }
var hiddenToolNames by rememberSaveable {
mutableStateOf(loadPdfHiddenTools(context).toList())
}
var toolOrderNames by rememberSaveable {
mutableStateOf(loadPdfToolOrder(context).map { it.name })
}
var bottomToolNames by rememberSaveable {
mutableStateOf(loadPdfBottomTools(context).toList())
}
val hiddenTools = remember(hiddenToolNames) {
sanitizePdfHiddenToolNames(hiddenToolNames)
}
val toolOrder = remember(toolOrderNames) {
restorePdfToolOrderNames(toolOrderNames)
}
val bottomTools = remember(bottomToolNames) {
sanitizePdfBottomToolNames(bottomToolNames)
}
val onUpdateHiddenTools = { newSet: Set<String> ->
hiddenTools = newSet
savePdfHiddenTools(context, newSet)
val sanitized = sanitizePdfHiddenToolNames(newSet)
hiddenToolNames = sanitized.toList()
savePdfHiddenTools(context, sanitized)
}
val onUpdateToolOrder = { newOrder: List<PdfReaderTool> ->
toolOrder = newOrder
savePdfToolOrder(context, newOrder)
val sanitized = restorePdfToolOrderNames(newOrder.map { it.name })
toolOrderNames = sanitized.map { it.name }
savePdfToolOrder(context, sanitized)
}
val onUpdateBottomTools = { newBottomTools: Set<String> ->
bottomTools = newBottomTools
savePdfBottomTools(context, newBottomTools)
val sanitized = sanitizePdfBottomToolNames(newBottomTools)
bottomToolNames = sanitized.toList()
savePdfBottomTools(context, sanitized)
}
val isOss = BuildConfig.FLAVOR == "oss"
@ -427,7 +454,7 @@ fun PdfViewerScreen(
val uiState by viewModel.uiState.collectAsState()
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
val isComicFile = effectiveFileType == FileType.CBZ || effectiveFileType == FileType.CBR || effectiveFileType == FileType.CB7
val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES
var showNewTabSheet by remember { mutableStateOf(false) }
var showFileInfoDialog by remember { mutableStateOf(false) }
@ -477,7 +504,7 @@ fun PdfViewerScreen(
var isAutoScrollTempPaused by remember { mutableStateOf(false) }
val autoScrollResumeJob = remember { mutableStateOf<Job?>(null) }
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
var ttsOverlaySize by remember(context) { mutableStateOf(loadReaderTtsOverlaySize(context)) }
var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) }
var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) }
@ -1339,22 +1366,50 @@ fun PdfViewerScreen(
saveMutex.withLock {
withContext(Dispatchers.IO) {
@Suppress("VariableNeverRead") var didSave = false
var sidecarsSaved = false
if (canSaveSidecarsSnapshot) {
if (force || annotsHash != lastSavedHashes[0]) {
if (annotsHash != lastSavedHashes[0]) {
logCloudAnnotationSyncTrace {
"android.reader.save_ink book=$bookId force=$force oldHash=${lastSavedHashes[0]} " +
"newHash=$annotsHash pages=${annots.keys.sorted()} count=${annots.values.sumOf { it.size }}"
}
annotationRepository.saveAnnotations(bookId, annots)
lastSavedHashes[0] = annotsHash
didSave = true
sidecarsSaved = true
} else if (force) {
logCloudAnnotationSyncTrace {
"android.reader.save_ink_noop book=$bookId force=true hash=$annotsHash"
}
}
if (force || boxesHash != lastSavedHashes[1]) {
if (boxesHash != lastSavedHashes[1]) {
logCloudAnnotationSyncTrace {
"android.reader.save_textboxes book=$bookId force=$force oldHash=${lastSavedHashes[1]} " +
"newHash=$boxesHash count=${boxes.size}"
}
textBoxRepository.saveTextBoxes(bookId, boxes)
lastSavedHashes[1] = boxesHash
didSave = true
sidecarsSaved = true
} else if (force) {
logCloudAnnotationSyncTrace {
"android.reader.save_textboxes_noop book=$bookId force=true hash=$boxesHash"
}
}
if (force || highlightsHash != lastSavedHashes[2]) {
if (highlightsHash != lastSavedHashes[2]) {
logCloudAnnotationSyncTrace {
"android.reader.save_highlights book=$bookId force=$force oldHash=${lastSavedHashes[2]} " +
"newHash=$highlightsHash count=${highlights.size}"
}
highlightRepository.saveHighlights(bookId, highlights)
lastSavedHashes[2] = highlightsHash
didSave = true
sidecarsSaved = true
} else if (force) {
logCloudAnnotationSyncTrace {
"android.reader.save_highlights_noop book=$bookId force=true hash=$highlightsHash"
}
}
} else {
Timber.tag("PdfTabSync").d(
@ -1384,6 +1439,12 @@ fun PdfViewerScreen(
}
lastSavedHashes[4] = page
}
if (sidecarsSaved) {
logCloudAnnotationSyncTrace {
"android.reader.sidecar_upload_queue book=$bookId force=$force"
}
viewModel.queuePdfSidecarCloudUpload(bookId)
}
}
}
}
@ -1391,6 +1452,44 @@ fun PdfViewerScreen(
}
}
val persistInkAnnotationsNow = remember(currentBookId, annotationRepository) {
{ annotationsSnapshot: Map<Int, List<PdfAnnotation>>, deletedAnnotations: Collection<PdfAnnotation>, reason: String ->
val bookIdSnapshot = currentBookId
val loadedSidecarBookIdSnapshot = currentLoadedSidecarBookId
val canSaveSidecarsSnapshot = canUsePdfSidecarsForBook(
bookIdSnapshot,
loadedSidecarBookIdSnapshot,
currentAreAnnotationsLoaded
)
viewModel.viewModelScope.launch {
val bookId = bookIdSnapshot ?: return@launch
if (!canSaveSidecarsSnapshot) {
logCloudAnnotationSyncTrace {
"android.reader.persist_ink_skip book=$bookId reason=$reason loadedSidecarBook=$loadedSidecarBookIdSnapshot"
}
return@launch
}
val deletedIds = deletedAnnotations.mapNotNull { it.id.takeIf(String::isNotBlank) }.toSet()
withContext(NonCancellable) {
saveMutex.withLock {
withContext(Dispatchers.IO) {
if (deletedIds.isNotEmpty()) {
annotationRepository.markAnnotationsDeleted(bookId, deletedIds)
}
annotationRepository.saveAnnotations(bookId, annotationsSnapshot)
lastSavedHashes[0] = annotationsSnapshot.hashCode()
}
}
}
logCloudAnnotationSyncTrace {
"android.reader.persist_ink book=$bookId reason=$reason count=${annotationsSnapshot.values.sumOf { it.size }} " +
"deletedIds=${deletedIds.sorted()}"
}
viewModel.queuePdfSidecarCloudUpload(bookId)
}
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
@ -2477,8 +2576,16 @@ fun PdfViewerScreen(
allAnnotations = loaded
textBoxes.addAll(loadedBoxes)
userHighlights.addAll(loadedHighlights)
lastSavedHashes[0] = loaded.hashCode()
lastSavedHashes[1] = loadedBoxes.hashCode()
lastSavedHashes[2] = loadedHighlights.hashCode()
loadedSidecarBookId = loadingBookId
areAnnotationsLoaded = true
logCloudAnnotationSyncTrace {
"android.reader.sidecar_load book=$loadingBookId inkPages=${loaded.keys.sorted()} " +
"inkCount=${loaded.values.sumOf { it.size }} textBoxes=${loadedBoxes.size} " +
"highlights=${loadedHighlights.size} hashes=${lastSavedHashes.copyOfRange(0, 3).joinToString()}"
}
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"ui.sidecarLoad.done bookId=$loadingBookId annotationPages=${loaded.keys.sorted()} " +
"textBoxes=${loadedBoxes.size} highlights=${loadedHighlights.size}"
@ -2646,7 +2753,6 @@ fun PdfViewerScreen(
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
var isFastScrubbing by remember { mutableStateOf(false) }
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
var startPageThumbnail by remember { mutableStateOf<Bitmap?>(null) }
val pdfSliderChromeVisible = shouldRenderReaderSlider(
isToggledOn = isPageSliderVisible,
isBottomChromeVisible = showStandardBars,
@ -3342,23 +3448,6 @@ fun PdfViewerScreen(
}
}
LaunchedEffect(pdfSliderChromeVisible, sliderStartPage, pdfDocument, totalPages) {
startPageThumbnail?.recycle()
startPageThumbnail = null
if (pdfSliderChromeVisible) {
val doc = pdfDocument
if (doc != null && totalPages > 0) {
Timber.d("Slider visible. Rendering thumbnail for page $sliderStartPage")
startPageThumbnail = renderPageToBitmap(doc, sliderStartPage)
Timber.d(
"Thumbnail rendering complete. Is bitmap null: ${startPageThumbnail == null}"
)
}
} else {
Timber.d("Slider hidden. Clearing thumbnail.")
}
}
LaunchedEffect(ttsState.currentText, ttsPageData, ttsState.startOffsetInSource) {
val currentText = ttsState.currentText
val currentTtsData = ttsPageData
@ -5084,8 +5173,14 @@ fun PdfViewerScreen(
val pageIdx = finalAnnotation.pageIndex
val existing =
allAnnotations[pageIdx] ?: emptyList()
allAnnotations =
val nextAnnotations =
allAnnotations + (pageIdx to (existing + finalAnnotation))
allAnnotations = nextAnnotations
persistInkAnnotationsNow(
nextAnnotations,
emptyList(),
"draw_end"
)
undoStack.add(
HistoryAction.Add(
pageIdx, finalAnnotation
@ -5099,6 +5194,11 @@ fun PdfViewerScreen(
erasedAnnotationsFromStroke.mapValues {
it.value.toList()
}
persistInkAnnotationsNow(
allAnnotations,
removalMap.values.flatten(),
"erase_end"
)
undoStack.add(
HistoryAction.Remove(removalMap)
)
@ -5559,8 +5659,14 @@ fun PdfViewerScreen(
val pageIdx = finalAnnotation.pageIndex
val existing =
allAnnotations[pageIdx] ?: emptyList()
allAnnotations =
val nextAnnotations =
allAnnotations + (pageIdx to (existing + finalAnnotation))
allAnnotations = nextAnnotations
persistInkAnnotationsNow(
nextAnnotations,
emptyList(),
"draw_end"
)
undoStack.add(
HistoryAction.Add(
pageIdx, finalAnnotation
@ -5574,6 +5680,11 @@ fun PdfViewerScreen(
erasedAnnotationsFromStroke.mapValues {
it.value.toList()
}
persistInkAnnotationsNow(
allAnnotations,
removalMap.values.flatten(),
"erase_end"
)
undoStack.add(
HistoryAction.Remove(removalMap)
)
@ -5916,6 +6027,42 @@ fun PdfViewerScreen(
pageText = pdfSliderPageText,
themePrimary = MaterialTheme.colorScheme.primary
)
val pdfSliderMaxPage = (totalDisplayPages - 1).coerceAtLeast(0)
val pdfSliderCurrentPage = sliderCurrentPage.roundToInt().coerceIn(0, pdfSliderMaxPage)
suspend fun scrollPdfSliderToPage(pageIndex: Int) {
val targetPage = pageIndex.coerceIn(0, pdfSliderMaxPage)
if (displayMode == DisplayMode.PAGINATION) {
scrollPaginationToDisplayPage(targetPage)
} else {
verticalReaderState.scrollToPage(targetPage)
}
}
fun jumpPdfSliderToPage(pageIndex: Int) {
val targetPage = pageIndex.coerceIn(0, pdfSliderMaxPage)
scrubDebounceJob.value?.cancel()
sliderCurrentPage = targetPage.toFloat()
isFastScrubbing = false
coroutineScope.launch {
scrollPdfSliderToPage(targetPage)
}
}
fun scrubPdfSliderToPage(newValue: Float) {
sliderCurrentPage = newValue.coerceIn(0f, pdfSliderMaxPage.toFloat())
isFastScrubbing = true
scrubDebounceJob.value?.cancel()
scrubDebounceJob.value = coroutineScope.launch {
delay(200)
if (isActive) {
val targetPage = newValue.roundToInt().coerceIn(0, pdfSliderMaxPage)
scrollPdfSliderToPage(targetPage)
sliderCurrentPage = targetPage.toFloat()
isFastScrubbing = false
}
}
}
// --- Slider UI attached to the bottom chrome ---
AnimatedVisibility(
@ -5926,144 +6073,79 @@ fun PdfViewerScreen(
.align(Alignment.BottomCenter)
.padding(bottom = pdfSliderBottomPadding)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(Modifier.height(72.dp))
Box(
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {}
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
BoxWithConstraints(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Slider(
value = sliderCurrentPage,
onValueChange = { newValue ->
sliderCurrentPage = newValue
isFastScrubbing = true
scrubDebounceJob.value?.cancel()
scrubDebounceJob.value = coroutineScope.launch {
delay(200)
if (isActive) {
val targetPage = newValue.roundToInt()
if (displayMode == DisplayMode.PAGINATION) {
scrollPaginationToDisplayPage(targetPage)
} else {
verticalReaderState.scrollToPage(targetPage)
}
isFastScrubbing = false
}
}
},
valueRange = 0f..(totalDisplayPages - 1).toFloat().coerceAtLeast(0f),
steps = if (totalDisplayPages > 2) totalDisplayPages - 2 else 0,
modifier = Modifier.fillMaxWidth(),
thumb = {
Surface(
modifier = Modifier.size(20.dp),
shape = CircleShape,
color = pdfReaderSliderColors.thumbColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {}
},
track = { sliderState ->
val trackHeight = 2.dp
val trackShape = RoundedCornerShape(trackHeight)
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
val fraction = if (range == 0f) 0f else {
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(trackHeight)
.background(
color = pdfReaderSliderColors.inactiveTrackColor,
shape = trackShape
)
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.background(
color = pdfReaderSliderColors.activeTrackColor,
shape = trackShape
)
)
}
}
)
val startPageOffsetFraction = if (totalDisplayPages > 1) {
sliderStartPage.toFloat() / (totalDisplayPages - 1)
} else {
0f
}
val thumbWidth = 20.dp
val trackWidth = maxWidth - thumbWidth
val startPagePixelPosition =
(trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
val indicatorSize = 8.dp
val indicatorOffset = startPagePixelPosition - (indicatorSize / 2)
Surface(
modifier = Modifier
.align(Alignment.CenterStart)
.offset(x = indicatorOffset)
.size(indicatorSize),
shape = CircleShape,
color = pdfReaderSliderColors.bookmarkColor
) {}
startPageThumbnail?.let { thumbnail ->
ThumbnailWithIndicator(
thumbnail = thumbnail,
borderColor = pdfReaderSliderColors.bookmarkColor,
modifier = Modifier
.graphicsLayer { clip = false }
.align(Alignment.TopStart)
.offset(
x = startPagePixelPosition - (45.dp / 2),
y = (-72).dp
),
onClick = {
sliderCurrentPage = sliderStartPage.toFloat()
coroutineScope.launch {
if (displayMode == DisplayMode.PAGINATION) {
scrollPaginationToDisplayPage(sliderStartPage)
} else {
verticalReaderState.scrollToPage(sliderStartPage)
}
}
}
IconButton(
onClick = {
jumpPdfSliderToPage(
readerSliderStepPage(
currentPage = pdfSliderCurrentPage,
delta = -1,
minPage = 0,
maxPage = pdfSliderMaxPage
)
}
}
)
},
enabled = pdfSliderCurrentPage > 0,
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = stringResource(R.string.desktop_previous_page),
tint = pdfReaderSliderColors.contentColor.copy(
alpha = if (pdfSliderCurrentPage > 0) 0.9f else 0.32f
)
)
}
Text(
text = pdfPageRangeText(
pageIndex = sliderCurrentPage.roundToInt(),
pageCount = totalDisplayPages,
displayMode = displayMode,
settings = pdfSpreadSettings
),
style = MaterialTheme.typography.bodyLarge,
color = pdfReaderSliderColors.contentColor,
fontSize = 18.sp
ReaderMinimalSlider(
value = sliderCurrentPage.coerceIn(0f, pdfSliderMaxPage.toFloat()),
onValueChange = ::scrubPdfSliderToPage,
valueRange = 0f..pdfSliderMaxPage.toFloat(),
enabled = pdfSliderMaxPage > 0,
activeColor = pdfReaderSliderColors.activeTrackColor,
inactiveColor = pdfReaderSliderColors.inactiveTrackColor,
thumbColor = pdfReaderSliderColors.thumbColor,
markerValue = sliderStartPage.toFloat(),
markerColor = pdfReaderSliderColors.bookmarkColor,
modifier = Modifier
.weight(1f)
.height(32.dp)
)
IconButton(
onClick = {
jumpPdfSliderToPage(
readerSliderStepPage(
currentPage = pdfSliderCurrentPage,
delta = 1,
minPage = 0,
maxPage = pdfSliderMaxPage
)
)
},
enabled = pdfSliderCurrentPage < pdfSliderMaxPage,
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = stringResource(R.string.desktop_next_page),
tint = pdfReaderSliderColors.contentColor.copy(
alpha = if (pdfSliderCurrentPage < pdfSliderMaxPage) 0.9f else 0.32f
)
)
}
}
@ -7199,7 +7281,7 @@ fun PdfViewerScreen(
)
val ttsAlignmentBias by animateFloatAsState(
targetValue = if (isTtsCollapsed) 1f else 0f,
targetValue = readerTtsOverlayAlignmentBias(ttsOverlaySize),
label = "TtsAlignAnimation"
)
@ -7216,8 +7298,11 @@ fun PdfViewerScreen(
ttsController = ttsController,
ttsState = ttsState,
currentTtsMode = currentTtsMode,
isCollapsed = isTtsCollapsed,
onCollapseChange = { isTtsCollapsed = it },
overlaySize = ttsOverlaySize,
onOverlaySizeChange = { newSize ->
ttsOverlaySize = newSize
saveReaderTtsOverlaySize(context, newSize)
},
onLocateCurrentChunk = {
ttsDisplayPageIndex?.let { targetPage ->
coroutineScope.launch {

View file

@ -68,6 +68,17 @@ private const val ZWSP = "\u200B"
internal fun String.hasRenderableRichText(): Boolean =
any { it != PAGE_BREAK_CHAR && !it.isWhitespace() }
internal fun androidPdfRichTextSelectionBounds(
selectionStart: Int,
selectionEnd: Int,
textLength: Int
): Pair<Int, Int>? {
val safeLength = textLength.coerceAtLeast(0)
val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
return if (localStart < localEnd) localStart to localEnd else null
}
object PdfFontCache {
private val cache = ConcurrentHashMap<String, FontFamily>()
private var assetManager: android.content.res.AssetManager? = null

View file

@ -12,6 +12,7 @@ import android.graphics.Rect
import android.graphics.RectF
import android.net.Uri
import android.os.Build
import com.aryan.reader.COMIC_ARCHIVE_FILE_TYPES
import com.aryan.reader.FileType
import com.aryan.reader.R
import com.aryan.reader.pptx.PptxDocumentWrapper
@ -106,7 +107,7 @@ object DocumentFactory {
throw e
}
PptxDocumentWrapper(cacheFile, deleteOnClose = true)
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
} else if (type in COMIC_ARCHIVE_FILE_TYPES) {
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { input ->
@ -534,7 +535,7 @@ class PdfTextPageWrapper(
}
}
// ================= CBZ, CBR, CB7 IMPLEMENTATION =================
// ================= CBZ, CBR, CB7, CBT IMPLEMENTATION =================
class DummyTextPage : ReaderTextPage {
override suspend fun textPageCountChars() = 0

View file

@ -31,6 +31,7 @@ import com.aryan.reader.pdf.PdfUserHighlight
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.util.Locale
import java.util.UUID
@ -143,7 +144,7 @@ object AnnotationSerializer {
resultMap[pageIndex]?.add(annotation)
}
} catch (e: Exception) {
e.printStackTrace()
Timber.e(e, "Failed to parse PDF ink annotations")
}
return resultMap
}
@ -217,7 +218,7 @@ object TextBoxSerializer {
)
}
} catch (e: Exception) {
e.printStackTrace()
Timber.e(e, "Failed to parse PDF text boxes")
}
return result
}
@ -290,7 +291,7 @@ object HighlightSerializer {
)
}
} catch (e: Exception) {
e.printStackTrace()
Timber.e(e, "Failed to parse PDF highlights")
}
return result
}

View file

@ -20,6 +20,8 @@
package com.aryan.reader.pdf.data
import android.content.Context
import com.aryan.reader.logCloudAnnotationSyncTrace
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -34,6 +36,13 @@ class PdfAnnotationRepository(private val context: Context) {
return File(dir, "annotation_$safeBookId.json")
}
private fun getDeletedFile(bookId: String): File {
val safeBookId = bookId.replace("/", "_")
val dir = File(context.filesDir, "annotations")
if (!dir.exists()) dir.mkdirs()
return File(dir, "deleted_annotation_$safeBookId.json")
}
suspend fun saveAnnotations(bookId: String, annotations: Map<Int, List<PdfAnnotation>>) {
withContext(Dispatchers.IO) {
try {
@ -47,7 +56,19 @@ class PdfAnnotationRepository(private val context: Context) {
val json = AnnotationSerializer.toJson(annotations)
val file = getFile(bookId)
if (file.exists() && file.readText() == json) {
logCloudAnnotationSyncTrace {
"android.repository.save_ink_noop book=$bookId count=${annotations.values.sumOf { it.size }} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
Timber.tag("AnnotationSync").d("Skipping unchanged annotation JSON for $bookId.")
return@withContext
}
file.writeText(json)
logCloudAnnotationSyncTrace {
"android.repository.save_ink book=$bookId count=${annotations.values.sumOf { it.size }} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}")
} catch (e: Exception) {
@ -83,4 +104,63 @@ class PdfAnnotationRepository(private val context: Context) {
return if (valid) file else null
}
suspend fun markAnnotationsDeleted(
bookId: String,
annotationIds: Collection<String>,
deletedAt: Long = System.currentTimeMillis()
) {
val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet()
if (ids.isEmpty()) return
withContext(Dispatchers.IO) {
val file = getDeletedFile(bookId)
val existing = if (file.isFile) {
SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText())
} else {
emptyMap()
}
val next = existing.toMutableMap()
ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, deletedAt) }
val json = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next)
if (file.isFile && file.readText() == json) return@withContext
file.writeText(json)
logCloudAnnotationSyncTrace {
"android.repository.mark_deleted_ink book=$bookId ids=${ids.sorted()} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
}
}
suspend fun replaceDeletedAnnotations(
bookId: String,
deletions: Map<String, Long>,
timestamp: Long? = null
) {
withContext(Dispatchers.IO) {
val file = getDeletedFile(bookId)
if (deletions.isEmpty()) {
if (file.exists()) file.delete()
return@withContext
}
val json = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions)
if (!file.isFile || file.readText() != json) {
file.writeText(json)
}
timestamp?.takeIf { it > 0L }?.let(file::setLastModified)
logCloudAnnotationSyncTrace {
"android.repository.replace_deleted_ink book=$bookId count=${deletions.size} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
}
}
fun getDeletedAnnotationsFileForSync(bookId: String): File? {
val file = getDeletedFile(bookId)
val deletions = if (file.isFile) {
SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText())
} else {
emptyMap()
}
return if (deletions.isNotEmpty()) file else null
}
}

View file

@ -2,6 +2,7 @@
package com.aryan.reader.pdf.data
import android.content.Context
import com.aryan.reader.logCloudAnnotationSyncTrace
import com.aryan.reader.pdf.PdfUserHighlight
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -25,7 +26,19 @@ class PdfHighlightRepository(private val context: Context) {
if (file.exists()) file.delete()
return@withContext
}
file.writeText(HighlightSerializer.toJson(highlights))
val json = HighlightSerializer.toJson(highlights)
if (file.exists() && file.readText() == json) {
logCloudAnnotationSyncTrace {
"android.repository.save_highlights_noop book=$bookId count=${highlights.size} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
return@withContext
}
file.writeText(json)
logCloudAnnotationSyncTrace {
"android.repository.save_highlights book=$bookId count=${highlights.size} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
} catch (e: Exception) {
Timber.e(e, "Failed to save local highlights")
}
@ -52,4 +65,4 @@ class PdfHighlightRepository(private val context: Context) {
val dir = File(context.filesDir, "pdf_highlights")
if (dir.exists()) dir.deleteRecursively()
}
}
}

View file

@ -20,6 +20,7 @@
package com.aryan.reader.pdf.data
import android.content.Context
import com.aryan.reader.logCloudAnnotationSyncTrace
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@ -35,13 +36,24 @@ class PdfTextBoxRepository(private val context: Context) {
suspend fun saveTextBoxes(bookId: String, textBoxes: List<PdfTextBox>) {
withContext(Dispatchers.IO) {
val file = getFile(bookId)
if (textBoxes.isEmpty()) {
val file = getFile(bookId)
if (file.exists()) file.delete()
return@withContext
}
val json = TextBoxSerializer.toJson(textBoxes)
getFile(bookId).writeText(json)
if (file.exists() && file.readText() == json) {
logCloudAnnotationSyncTrace {
"android.repository.save_textboxes_noop book=$bookId count=${textBoxes.size} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
return@withContext
}
file.writeText(json)
logCloudAnnotationSyncTrace {
"android.repository.save_textboxes book=$bookId count=${textBoxes.size} " +
"bytes=${file.length()} ts=${file.lastModified()}"
}
}
}
@ -71,4 +83,4 @@ class PdfTextBoxRepository(private val context: Context) {
val file = getFile(bookId)
if(file.exists()) file.delete()
}
}
}

View file

@ -81,6 +81,13 @@ internal fun resolveNativeTtsVoiceForBuild(
}
}
internal fun shouldResolveNativeTtsVoice(
preferredVoiceName: String?,
isOfflineBuild: Boolean
): Boolean {
return isOfflineBuild || !preferredVoiceName.isNullOrBlank()
}
class BaseTtsSynthesizer(private val context: Context) {
private var tts: TextToSpeech? = null
@ -144,15 +151,6 @@ class BaseTtsSynthesizer(private val context: Context) {
if (status == TextToSpeech.SUCCESS) {
isInitialized = true
Timber.d("TextToSpeech engine initialized successfully.")
try {
val result = tts?.setLanguage(Locale.getDefault())
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
Timber.e("Default language not supported/missing data")
}
} catch (e: Exception) {
Timber.e(e, "Error setting language")
}
tts?.setOnUtteranceProgressListener(sharedListener)
if (continuation.isActive) continuation.resume(Unit)
} else {
@ -183,6 +181,9 @@ class BaseTtsSynthesizer(private val context: Context) {
try {
val preferredVoiceName = loadNativeVoice(context)
if (!shouldResolveNativeTtsVoice(preferredVoiceName, BuildConfig.IS_OFFLINE)) {
return
}
val defaultLocale = Locale.getDefault()
val defaultVoice = tts?.defaultVoice
val availableVoices = tts?.voices
@ -195,7 +196,6 @@ class BaseTtsSynthesizer(private val context: Context) {
)
if (targetVoice == null) {
tts?.language = defaultLocale
Timber.w("BaseTts: No suitable local voice found for locale $defaultLocale.")
return
}
@ -208,15 +208,10 @@ class BaseTtsSynthesizer(private val context: Context) {
Timber.w("BaseTts: Saved voice '$preferredVoiceName' requires network or is unavailable in offline build. Using ${targetVoice.name}.")
}
if (tts?.voice?.name != targetVoice.name) {
Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})")
try {
tts?.language = targetVoice.locale
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to set language for voice")
}
tts?.voice = targetVoice
}
Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})")
tts?.voice = targetVoice
} catch (e: OutOfMemoryError) {
Timber.e(e, "BaseTts: Skipping optional voice selection due to low memory")
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to apply preferred voice")
}

View file

@ -74,20 +74,16 @@ fun ReaderTtsMiniBar(
ttsState.currentChunkIndex >= 0 &&
ttsState.currentChunkIndex < ttsState.totalChunks - 1
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
"Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
} else {
null
}
formatReaderTtsChunkLabel(ttsState.currentChunkIndex, ttsState.totalChunks)
}
val title = ttsState.bookTitle
?.takeIf { it.isNotBlank() }
?: stringResource(R.string.action_read_aloud)
val subtitle = remember(title, ttsState.chapterTitle, chunkLabel) {
listOfNotNull(
chunkLabel,
ttsState.chapterTitle
?.takeIf { it.isNotBlank() && it != title },
chunkLabel
?.takeIf { it.isNotBlank() && it != title }
).joinToString(" - ")
}

View file

@ -0,0 +1,42 @@
package com.aryan.reader.tts
import android.content.Context
import androidx.core.content.edit
enum class ReaderTtsOverlaySize {
LARGE,
MEDIUM,
SMALL
}
private const val READER_PREFS_NAME = "reader_prefs"
private const val READER_TTS_OVERLAY_SIZE_KEY = "reader_tts_overlay_size"
internal fun readerTtsOverlayAlignmentBias(size: ReaderTtsOverlaySize): Float {
return if (size == ReaderTtsOverlaySize.SMALL) 1f else 0f
}
internal fun readerTtsOverlayAlternativeSizes(size: ReaderTtsOverlaySize): List<ReaderTtsOverlaySize> {
return ReaderTtsOverlaySize.entries.filter { it != size }
}
internal fun resolveReaderTtsOverlaySize(savedName: String?): ReaderTtsOverlaySize {
return ReaderTtsOverlaySize.entries.firstOrNull { it.name == savedName }
?: ReaderTtsOverlaySize.LARGE
}
internal fun loadReaderTtsOverlaySize(context: Context): ReaderTtsOverlaySize {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
return resolveReaderTtsOverlaySize(prefs.getString(READER_TTS_OVERLAY_SIZE_KEY, null))
}
internal fun saveReaderTtsOverlaySize(context: Context, size: ReaderTtsOverlaySize) {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(READER_TTS_OVERLAY_SIZE_KEY, size.name) }
}
internal fun formatReaderTtsChunkLabel(currentChunkIndex: Int, totalChunks: Int): String? {
if (totalChunks <= 0) return null
if (currentChunkIndex !in 0 until totalChunks) return null
return "Chunk ${currentChunkIndex + 1}/$totalChunks"
}

View file

@ -155,6 +155,13 @@ internal fun shouldStartTtsTransitionPrefetch(
return currentGeneration != deferredGeneration
}
internal fun shouldStopTtsPrefetchAfterMissingChunk(
isLoaded: Boolean,
playlistIndex: Int?
): Boolean {
return !isLoaded && playlistIndex == null
}
internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? {
if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null
return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS)
@ -1592,11 +1599,21 @@ class TtsPlaybackManager(
)
return@launch
}
if (!loadedChunks.contains(targetIndex) && findPlaylistIndexForChunk(targetIndex) == null) {
logChunkNavWarnMain(
"prefetch-stop-after-missing-chunk",
"Stopping TTS prefetch after missing chunk $targetIndex to keep playlist contiguous."
)
val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) {
val playlistIndex = findPlaylistIndexForChunk(targetIndex)
shouldStopTtsPrefetchAfterMissingChunk(
isLoaded = loadedChunks.contains(targetIndex),
playlistIndex = playlistIndex
).also { shouldStop ->
if (shouldStop) {
logChunkNavWarnMain(
"prefetch-stop-after-missing-chunk",
"Stopping TTS prefetch after missing chunk $targetIndex to keep playlist contiguous."
)
}
}
}
if (shouldStopAfterMissingChunk) {
return@launch
}
}

View file

@ -113,7 +113,31 @@ fun formatBytes(bytes: Long): String {
}
class TtsCacheManager(private val context: Context) {
private fun sanitize(name: String): String = name.replace(Regex("[^a-zA-Z0-9.-]"), "_")
private val baseDir: File
get() = File(context.filesDir, "TTS_Cache")
private fun safeCacheSegment(name: String, fallback: String): String {
val normalized = name.trim().takeIf { it.isNotBlank() } ?: fallback
val slug = normalized
.replace(Regex("[^a-zA-Z0-9_-]+"), "_")
.trim('_', '-')
.ifBlank { fallback }
.take(48)
return "${slug}_${hash(normalized).take(16)}"
}
private fun sanitizeFileToken(name: String): String {
return name
.replace(Regex("[^a-zA-Z0-9._-]+"), "_")
.trim('.', '_', '-')
.ifBlank { "default" }
}
private fun bookDirName(bookTitle: String): String = safeCacheSegment(bookTitle, "book")
private fun chapterDirName(chapterTitle: String?): String {
return safeCacheSegment(chapterTitle ?: "Unknown_Chapter", "chapter")
}
private fun hash(input: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
@ -121,9 +145,8 @@ class TtsCacheManager(private val context: Context) {
}
fun saveTotalChunks(bookTitle: String, chapterTitle: String?, totalChunks: Int) {
val baseDir = File(context.filesDir, "TTS_Cache")
val bookDir = File(baseDir, sanitize(bookTitle.take(50)))
val chapterDir = File(bookDir, sanitize((chapterTitle ?: "Unknown_Chapter").take(50)))
val bookDir = getBookCacheDir(bookTitle)
val chapterDir = File(bookDir, chapterDirName(chapterTitle))
if (!chapterDir.exists()) chapterDir.mkdirs()
val metaFile = File(chapterDir, "total_chunks.txt")
metaFile.writeText(totalChunks.toString())
@ -137,22 +160,20 @@ class TtsCacheManager(private val context: Context) {
speakerId: String,
mode: TtsPlaybackManager.TtsMode
): File {
val baseDir = File(context.filesDir, "TTS_Cache")
val bookDir = File(baseDir, sanitize(bookTitle.take(50)))
val chapterDir = File(bookDir, sanitize((chapterTitle ?: "Unknown_Chapter").take(50)))
val bookDir = getBookCacheDir(bookTitle)
val chapterDir = File(bookDir, chapterDirName(chapterTitle))
if (!chapterDir.exists()) {
chapterDir.mkdirs()
}
val hashParams = hash(text + speakerId + mode.name)
val safeSpeaker = sanitize(speakerId)
val safeSpeaker = sanitizeFileToken(speakerId)
return File(chapterDir, "cached_chunk_${safeSpeaker}_$hashParams.wav")
}
fun getBookCacheDir(bookTitle: String): File {
val baseDir = File(context.filesDir, "TTS_Cache")
return File(baseDir, sanitize(bookTitle.take(50)))
return File(baseDir, bookDirName(bookTitle))
}
fun getChapterCaches(bookTitle: String, speakerFilter: String? = null): List<TtsChapterCacheInfo> {
@ -194,18 +215,35 @@ class TtsCacheManager(private val context: Context) {
}
fun deleteChapterCache(chapterDir: File) {
chapterDir.deleteRecursively()
if (chapterDir.isInsideBaseDir()) {
chapterDir.deleteRecursively()
}
}
fun deleteSpecificFiles(files: List<File>, chapterDir: File) {
files.forEach { it.delete() }
if (chapterDir.listFiles()?.isEmpty() == true) {
if (!chapterDir.isInsideBaseDir()) return
files.forEach { file ->
if (file.isInside(chapterDir)) {
file.delete()
}
}
if (chapterDir.listFiles()?.isEmpty() == true && chapterDir.isInsideBaseDir()) {
chapterDir.deleteRecursively()
}
}
fun clearBookCache(bookTitle: String) {
getBookCacheDir(bookTitle).deleteRecursively()
getBookCacheDir(bookTitle).takeIf { it.isInsideBaseDir() }?.deleteRecursively()
}
private fun File.isInsideBaseDir(): Boolean {
return isInside(baseDir)
}
private fun File.isInside(root: File): Boolean {
val rootPath = runCatching { root.canonicalFile.path }.getOrNull() ?: return false
val targetPath = runCatching { canonicalFile.path }.getOrNull() ?: return false
return targetPath != rootPath && targetPath.startsWith(rootPath + File.separator)
}
}