Linux support (#381)

* Add desktop release CI and support for Arch Linux packaging

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Configure Gradle and update Java environment in desktop-release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Add MSIX packaging support for Windows desktop distribution

* Update AUR packaging metadata and validation

* Use spine toc attribute for NCX resolution

* crash fixes

* Implement automatic discovery and injection of EPUB font face siblings

* Enhance custom font support with family grouping and variable font handling

* Optimize metadata loading and improve TTS highlighting

* Add keyboard navigation support for EPUB reader

* Refine PDF spread page sizing to respect aspect ratios

* Implement responsive maximum height for reader popups and sheets

* Handle TTS generation failures by skipping problematic chunks

* Refactor PDF tile rendering logic and zoom indicator behavior

* Prefer block and offset locators over page index in native vertical flow

* Implement save and share actions for original book files

* Add Estonian language support

* Implement temporary viewing mode for external files

* Implement direct opening for temporary external files without library persistence

* fix failing tests

* Import SharedFileCapabilities in DesktopLibraryUi

* Improve native vertical reader progress, persistence, and image support

* Center target in viewport for native vertical reader and support animated scrolling
This commit is contained in:
Aryan 2026-06-14 13:43:49 +05:30 committed by GitHub
parent a13d6599d1
commit 625a4d5d2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 6012 additions and 687 deletions

View file

@ -320,6 +320,8 @@ class LibraryScreenContentTest {
onItemClick = {},
onItemLongClick = { item -> selectedItems.value = setOf(item) },
onInfoClick = onInfoClick,
onSaveClick = null,
onShareClick = null,
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick,
onShelfClick = onShelfClick,

View file

@ -55,7 +55,22 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".TemporaryExternalFileActivity"
android:exported="false"
android:theme="@style/Theme.Reader"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:excludeFromRecents="true"
android:launchMode="standard" />
<activity
android:name=".ExternalFileOpenRouterActivity"
android:exported="true"
android:theme="@style/Theme.App.Starting"
android:noHistory="true"
android:excludeFromRecents="true">
<!-- PDF -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View file

@ -128,8 +128,15 @@
background-color: rgba(255, 236, 179, 0.8);
/* Semi-transparent Gold */
color: black !important;
display: inline !important;
text-align: initial !important;
text-align-last: auto !important;
letter-spacing: normal !important;
word-spacing: normal !important;
padding: 0.1em 0;
border-radius: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
html.dark-theme span.tts-highlight {
@ -1496,6 +1503,16 @@
};
const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS";
const TTS_HIGHLIGHT_BLOCK_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th";
function getTtsHighlightBlock(node) {
if (!node) {
return document.body;
}
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return (element && element.closest(TTS_HIGHLIGHT_BLOCK_SELECTOR)) || document.body;
}
window.highlightFromCfi = function (cfi, textToHighlight, startOffset) {
console.log(`$ {
@ -1556,9 +1573,10 @@
, Text content: '${(location.node.textContent || "").substring(0, 50)}...' `);
const baseNode = location.node;
const highlightRoot = getTtsHighlightBlock(baseNode);
let remainingOffset = startOffset;
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
const treeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
treeWalker.currentNode = baseNode;
let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode();
@ -1626,7 +1644,7 @@
} else {
remainingTextLength -= availableLength;
// Important: We need a fresh walker starting from the endNode to find the *next* text node reliably
const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
const nextNodeWalker = document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT, null, false);
nextNodeWalker.currentNode = endNode;
endNode = nextNodeWalker.nextNode();
endOffset = 0; // Start from the beginning of the next node
@ -1677,11 +1695,14 @@
TTS_HIGHLIGHT_LOG_TAG
}
: surroundContents failed, using fallback. Error: $ {
: surroundContents failed, using same-block fallback. Error: $ {
e.message
}
`);
if (!highlightRoot.contains(range.commonAncestorContainer)) {
return "JS: Highlight range escaped current block.";
}
const contents = range.extractContents();
highlightSpan.appendChild(contents);
range.insertNode(highlightSpan);

View file

@ -220,11 +220,17 @@ fun AppNavigation(
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
MainScreen(
viewModel = viewModel,
windowSizeClass = windowSizeClass,
navController = navController
)
if (uiState.isTemporaryExternalOpen) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
MainScreen(
viewModel = viewModel,
windowSizeClass = windowSizeClass,
navController = navController
)
}
}
// PDF Viewer Screen Composable

View file

@ -43,6 +43,7 @@ data class ReaderScreenState(
val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null,
val isLoading: Boolean = false,
val isTemporaryExternalOpen: Boolean = false,
val errorMessage: String? = null,
val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,

View file

@ -0,0 +1,31 @@
package com.aryan.reader
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import timber.log.Timber
internal fun copyPlainTextToClipboard(
context: Context,
label: String,
text: String
): Boolean {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(label, text)
return setPrimaryClipSafely {
clipboard.setPrimaryClip(clip)
}
}
internal inline fun setPrimaryClipSafely(setPrimaryClip: () -> Unit): Boolean {
return try {
setPrimaryClip()
true
} catch (e: SecurityException) {
Timber.w(e, "Clipboard write rejected by system policy")
false
} catch (e: RuntimeException) {
Timber.w(e, "Clipboard write failed")
false
}
}

View file

@ -138,6 +138,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@ -862,6 +863,13 @@ fun AiDefinitionPopup(
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
val context = LocalContext.current
val configuration = LocalConfiguration.current
val maxPopupHeight = readerModalMaxHeightDp(
screenHeightDp = configuration.screenHeightDp,
fraction = 0.65f,
verticalMarginDp = 48,
preferredMinHeightDp = 180
).dp
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
@ -882,7 +890,7 @@ fun AiDefinitionPopup(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 5.dp)
.heightIn(min = 150.dp, max = 400.dp),
.heightIn(max = maxPopupHeight),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
@ -3310,12 +3318,16 @@ fun ThemeColorPickerDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
val configuration = LocalConfiguration.current
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
.heightIn(max = maxDialogHeight)
) {
Column(
modifier = Modifier
@ -3495,12 +3507,16 @@ fun HighlightColorPickerDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
val configuration = LocalConfiguration.current
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
.heightIn(max = maxDialogHeight)
) {
Column(
modifier = Modifier

View file

@ -0,0 +1,57 @@
package com.aryan.reader
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
const val EXTRA_TEMPORARY_EXTERNAL_OPEN = "com.aryan.reader.extra.TEMPORARY_EXTERNAL_OPEN"
object ExternalFileOpenRouteDecider {
const val BEHAVIOR_TEMPORARY = "TEMPORARY"
fun shouldOpenTemporary(externalFileBehavior: String?): Boolean {
return externalFileBehavior == BEHAVIOR_TEMPORARY
}
fun targetActivityClass(externalFileBehavior: String?): Class<out Activity> {
return if (shouldOpenTemporary(externalFileBehavior)) {
TemporaryExternalFileActivity::class.java
} else {
MainActivity::class.java
}
}
}
class ExternalFileOpenRouterActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
routeExternalOpen(intent)
finish()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
routeExternalOpen(intent)
finish()
}
private fun routeExternalOpen(sourceIntent: Intent?) {
if (sourceIntent?.action != Intent.ACTION_VIEW || sourceIntent.data == null) return
val prefs = getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val behavior = prefs.getString("external_file_behavior", "ASK")
val temporary = ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior)
val targetIntent = Intent(sourceIntent).apply {
setClass(this@ExternalFileOpenRouterActivity, ExternalFileOpenRouteDecider.targetActivityClass(behavior))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (temporary) {
putExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, true)
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
}
}
startActivity(targetIntent)
}
}
class TemporaryExternalFileActivity : MainActivity()

View file

@ -64,10 +64,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.CustomFontFamilyItem
import com.aryan.reader.shared.CustomFontVariantItem
import com.aryan.reader.shared.fontFaceLabel
import com.aryan.reader.shared.fontFaceSummary
import com.aryan.reader.shared.groupByFamily
import com.aryan.reader.shared.hasVariableWeightFace
import com.aryan.reader.shared.ui.SharedAppFontSelector
import com.aryan.reader.shared.ui.SharedFontSettingsSection
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
@ -97,6 +104,7 @@ fun FontsScreen(
val selectedFonts = remember(fonts, selectedFontIds) {
fonts.filter { it.id in selectedFontIds }
}
val fontEntitiesById = remember(fonts) { fonts.associateBy { it.id } }
val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
LaunchedEffect(fonts) {
@ -172,6 +180,7 @@ fun FontsScreen(
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
val fontFamilies = remember(sharedFonts) { sharedFonts.groupByFamily() }
Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs(
selectedSection = selectedSection,
@ -202,16 +211,20 @@ fun FontsScreen(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(fonts, key = { it.id }) { font ->
FontListItem(
font = font,
isSelected = font.id in selectedFontIds,
items(fontFamilies, key = { family -> family.variants.joinToString("|") { it.font.id } }) { family ->
FontFamilyListItem(
family = family,
selectedFontIds = selectedFontIds,
isSelectionMode = isFontSelectionMode,
onSelectionToggle = {
selectedFontIds = selectedFontIds.toggle(font.id)
fontEntityForId = { id -> fontEntitiesById[id] },
onVariantSelectionToggle = { id ->
selectedFontIds = selectedFontIds.toggle(id)
},
onDelete = {
fontsPendingDelete = listOf(font)
onFamilySelectionToggle = {
selectedFontIds = selectedFontIds.toggleAll(family.variants.map { it.font.id })
},
onDeleteVariant = { id ->
fontEntitiesById[id]?.let { fontsPendingDelete = listOf(it) }
}
)
}
@ -531,6 +544,188 @@ fun FontListItem(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FontFamilyListItem(
family: CustomFontFamilyItem,
selectedFontIds: Set<String>,
isSelectionMode: Boolean,
fontEntityForId: (String) -> CustomFontEntity?,
onVariantSelectionToggle: (String) -> Unit,
onFamilySelectionToggle: () -> Unit,
onDeleteVariant: (String) -> Unit
) {
val baseFont = remember(family) {
family.variants.firstOrNull { it.fontFaceLabel() == "Regular" }?.font ?: family.variants.first().font
}
val customTypeface = remember(baseFont.path) {
try {
FontFamily(Font(File(baseFont.path)))
} catch (_: Exception) {
null
}
}
val familyFontIds = remember(family) { family.variants.map { it.font.id }.toSet() }
val isSelected = familyFontIds.any { it in selectedFontIds }
val allSelected = familyFontIds.all { it in selectedFontIds }
val faceSummary = remember(family) {
buildString {
append(family.fontFaceSummary())
if (family.hasVariableWeightFace()) append(" - Variable weight")
append(" - ${family.variants.size} file")
if (family.variants.size != 1) append("s")
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (isSelectionMode) {
onFamilySelectionToggle()
}
},
onLongClick = onFamilySelectionToggle
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (isSelectionMode) {
Checkbox(
checked = allSelected,
onCheckedChange = { onFamilySelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = family.familyName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = faceSummary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
.padding(12.dp)
) {
if (customTypeface != null) {
Text(
text = stringResource(R.string.font_preview_text),
fontFamily = customTypeface,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
text = stringResource(R.string.font_preview_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(8.dp))
family.variants.forEachIndexed { index, variant ->
FontVariantRow(
variant = variant,
entity = fontEntityForId(variant.font.id),
isSelected = variant.font.id in selectedFontIds,
isSelectionMode = isSelectionMode,
onSelectionToggle = { onVariantSelectionToggle(variant.font.id) },
onDelete = { onDeleteVariant(variant.font.id) }
)
if (index != family.variants.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(start = if (isSelectionMode) 48.dp else 0.dp))
}
}
}
}
}
@Composable
private fun FontVariantRow(
variant: CustomFontVariantItem,
entity: CustomFontEntity?,
isSelected: Boolean,
isSelectionMode: Boolean,
onSelectionToggle: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable(enabled = isSelectionMode) { onSelectionToggle() }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = variant.fontFaceLabel(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = entity?.fileName ?: variant.font.fileName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = variant.font.fileExtension.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = 8.dp)
)
if (!isSelectionMode) {
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() }
@ -591,3 +786,8 @@ fun DeleteFontsConfirmationDialog(
private fun Set<String>.toggle(id: String): Set<String> {
return if (id in this) this - id else this + id
}
private fun Set<String>.toggleAll(ids: List<String>): Set<String> {
val idSet = ids.toSet()
return if (containsAll(idSet)) this - idSet else this + idSet
}

View file

@ -139,6 +139,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
@ -193,6 +194,7 @@ fun HomeScreen(
var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
var showBehaviorDialog by remember { mutableStateOf(false) }
var showStrictFilterDialog by remember { mutableStateOf(false) }
var showClearBookCacheDialog by remember { mutableStateOf(false) }
@ -221,6 +223,35 @@ fun HomeScreen(
}
}
val saveOriginalLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val item = pendingSaveOriginalItem
pendingSaveOriginalItem = null
if (uri != null && item?.uriString != null) {
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
}
}
fun saveOriginalItem(item: RecentFileItem) {
if (!item.canExportOriginalFile()) return
pendingSaveOriginalItem = item
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
}
fun shareOriginalItem(item: RecentFileItem) {
val uriString = item.uriString ?: return
if (!item.canExportOriginalFile()) return
scope.launch {
viewModel.shareOriginalFile(
activityContext = context,
sourceUri = uriString.toUri(),
fileType = item.type,
filename = item.suggestedOriginalFileName()
)
}
}
LaunchedEffect(uiState.isRequestingDrivePermission) {
if (uiState.isRequestingDrivePermission) {
val intent = viewModel.getDriveSignInIntent(context)
@ -390,6 +421,12 @@ fun HomeScreen(
showInfoDialog = true
}
},
onSaveClick = selectedContextItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedContextItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() })
@ -502,28 +539,17 @@ fun HomeScreen(
)
}
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(item.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
HydratedFileInfoDialog(
item = itemForInfoDialog,
isVisible = showInfoDialog,
uiState = uiState,
viewModel = viewModel,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
)
if (showClearReflowCacheDialog) {
DangerousFolderActionDialog(
@ -1786,9 +1812,14 @@ fun ExternalFileBehaviorDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.options_external_file_behavior)) },
text = {
Column {
val options = listOf("ASK" to R.string.external_file_behavior_ask, "KEEP" to R.string.external_file_behavior_keep, "DELETE" to R.string.external_file_behavior_delete)
options.forEach { (value, labelRes) ->
Column(modifier = Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
val options = listOf(
Triple("ASK", R.string.external_file_behavior_ask, R.string.external_file_behavior_ask_desc),
Triple("KEEP", R.string.external_file_behavior_keep, R.string.external_file_behavior_keep_desc),
Triple("DELETE", R.string.external_file_behavior_delete, R.string.external_file_behavior_delete_desc),
Triple("TEMPORARY", R.string.external_file_behavior_temporary, R.string.external_file_behavior_temporary_desc)
)
options.forEach { (value, labelRes, descriptionRes) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@ -1798,7 +1829,14 @@ fun ExternalFileBehaviorDialog(
) {
RadioButton(selected = currentBehavior == value, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(labelRes))
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(labelRes))
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View file

@ -133,6 +133,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
@ -271,6 +272,36 @@ fun LibraryScreen(
var showDeleteShelvesDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
val saveOriginalLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val item = pendingSaveOriginalItem
pendingSaveOriginalItem = null
if (uri != null && item?.uriString != null) {
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
}
}
fun saveOriginalItem(item: RecentFileItem) {
if (!item.canExportOriginalFile()) return
pendingSaveOriginalItem = item
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
}
fun shareOriginalItem(item: RecentFileItem) {
val uriString = item.uriString ?: return
if (!item.canExportOriginalFile()) return
scope.launch {
viewModel.shareOriginalFile(
activityContext = context,
sourceUri = uriString.toUri(),
fileType = item.type,
filename = item.suggestedOriginalFileName()
)
}
}
BackHandler(enabled = isContextualModeActive) {
viewModel.clearContextualAction()
@ -317,6 +348,12 @@ fun LibraryScreen(
showInfoDialog = true
}
},
onSaveClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllLibraryFiles() },
onShelfClick = viewModel::onShelfClick,
@ -397,28 +434,17 @@ fun LibraryScreen(
)
}
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(item.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(item.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(item.bookId)
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
HydratedFileInfoDialog(
item = itemForInfoDialog,
isVisible = showInfoDialog,
uiState = uiState,
viewModel = viewModel,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
)
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@ -436,10 +462,42 @@ fun ShelfScreen(
val sortOrder = uiState.sortOrder
val showRenameDialogFor = uiState.showRenameShelfDialogFor
val showDeleteDialogFor = uiState.showDeleteShelfDialogFor
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showRemoveFromShelfDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var pendingSaveOriginalItem by remember { mutableStateOf<RecentFileItem?>(null) }
val saveOriginalLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val item = pendingSaveOriginalItem
pendingSaveOriginalItem = null
if (uri != null && item?.uriString != null) {
viewModel.saveOriginalFile(item.uriString.toUri(), uri)
}
}
fun saveOriginalItem(item: RecentFileItem) {
if (!item.canExportOriginalFile()) return
pendingSaveOriginalItem = item
saveOriginalLauncher.launch(item.suggestedOriginalFileName())
}
fun shareOriginalItem(item: RecentFileItem) {
val uriString = item.uriString ?: return
if (!item.canExportOriginalFile()) return
scope.launch {
viewModel.shareOriginalFile(
activityContext = context,
sourceUri = uriString.toUri(),
fileType = item.type,
filename = item.suggestedOriginalFileName()
)
}
}
BackHandler(enabled = true) {
when {
@ -491,6 +549,12 @@ fun ShelfScreen(
showInfoDialog = true
}
},
onSaveClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { saveOriginalItem(item) } },
onShareClick = selectedItems.singleOrNull()
?.takeIf { it.canExportOriginalFile() }
?.let { item -> { shareOriginalItem(item) } },
onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
@ -531,19 +595,14 @@ fun ShelfScreen(
)
}
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
onRestoreMetadata = { viewModel.restoreOriginalBookMetadata(item.bookId) },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
HydratedFileInfoDialog(
item = itemForInfoDialog,
isVisible = showInfoDialog,
uiState = uiState,
viewModel = viewModel,
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onOpenTags = { bookId -> viewModel.openTagSelection(setOf(bookId)) }
)
CustomTopBanner(bannerMessage = uiState.bannerMessage)
}
}
@ -578,6 +637,8 @@ fun LibraryScreenContent(
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onInfoClick: () -> Unit,
onSaveClick: (() -> Unit)?,
onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit,
onShelfClick: (Shelf) -> Unit,
@ -640,6 +701,8 @@ fun LibraryScreenContent(
onTagClick = onTagClick,
onPinClick = onPinClick,
onInfoClick = onInfoClick,
onSaveClick = onSaveClick,
onShareClick = onShareClick,
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick
)
@ -1046,6 +1109,8 @@ private fun ShelfDetailScreen(
onClearSelection: () -> Unit,
onTagClick: () -> Unit,
onInfoClick: () -> Unit,
onSaveClick: (() -> Unit)?,
onShareClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit,
@ -1128,6 +1193,8 @@ private fun ShelfDetailScreen(
onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onInfoClick = onInfoClick,
onSaveClick = onSaveClick,
onShareClick = onShareClick,
onDeleteClick = onDeleteClick
)
} else if (isSearchActive) {

View file

@ -58,10 +58,12 @@ import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
@UnstableApi
class MainActivity : AppCompatActivity() {
open class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
private val isTemporaryExternalOpen: Boolean
get() = intent?.getBooleanExtra(EXTRA_TEMPORARY_EXTERNAL_OPEN, false) == true
private val updateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
@ -86,6 +88,14 @@ class MainActivity : AppCompatActivity() {
}
}
lifecycleScope.launch {
viewModel.temporaryExternalOpenFinished.collect {
if (isTemporaryExternalOpen) {
finishAndRemoveTask()
}
}
}
if (savedInstanceState == null) {
handleIntent(intent)
}
@ -160,7 +170,12 @@ class MainActivity : AppCompatActivity() {
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!!
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true)
viewModel.onFileSelected(
uri,
isFromRecent = false,
isExternalIntent = true,
isTemporaryExternalIntent = isTemporaryExternalOpen
)
}
}
}

View file

@ -222,10 +222,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val _navigationEvent = Channel<NavigationEvent>(Channel.BUFFERED)
@Suppress("unused")
val navigationEvent = _navigationEvent.receiveAsFlow()
private val _temporaryExternalOpenFinished = Channel<Unit>(Channel.BUFFERED)
val temporaryExternalOpenFinished = _temporaryExternalOpenFinished.receiveAsFlow()
private var bannerDismissJob: Job? = null
private var bannerDismissGeneration = 0L
private var pendingSwitchDeferred: CompletableDeferred<Boolean>? = null
private var externalOpenedBookId: String? = null
private var temporaryExternalSessionBookId: String? = null
private var cloudContentRetryJob: Job? = null
private val cloudMetadataUploadJobs = ConcurrentHashMap<String, Job>()
@ -804,6 +807,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) }
}
suspend fun getFileInfoItem(bookId: String): RecentFileItem? {
return recentFilesRepository.getFileByBookId(bookId)
}
fun createAndAssignTag(name: String, bookIds: Set<String>) {
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
if (sanitizedBookIds.isEmpty()) return
@ -2089,6 +2096,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
internal fun trackExternalOpenForClose(
bookId: String,
importedCopyUriString: String?,
isTemporaryExternalIntent: Boolean
) {
if (isTemporaryExternalIntent) {
temporaryExternalSessionBookId = bookId
if (importedCopyUriString != null) {
externalOpenedBookId = bookId
markPendingExternalFileRemoval(bookId, importedCopyUriString)
}
return
}
externalOpenedBookId = bookId
if (
importedCopyUriString != null &&
prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) == "DELETE"
) {
markPendingExternalFileRemoval(bookId, importedCopyUriString)
}
}
fun saveOriginalFile(sourceUri: Uri, destUri: Uri) {
viewModelScope.launch {
_internalState.update {
it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_original_file)))
}
try {
withContext(Dispatchers.IO) {
copyUriBytes(sourceUri, destUri)
}
showBanner(appContext.getString(R.string.banner_original_file_saved))
} catch (e: Exception) {
Timber.e(e, "Failed to save original file")
showBanner(appContext.getString(R.string.error_saving_file, e.localizedMessage ?: e.message.orEmpty()), isError = true)
} finally {
_internalState.update { it.copy(isLoading = false) }
}
}
}
fun togglePinForContextualItems(isHome: Boolean) {
if (_internalState.value.contextualActionItems.isEmpty()) return
@ -2206,6 +2255,68 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
suspend fun shareOriginalFile(
activityContext: Context,
sourceUri: Uri,
fileType: FileType,
filename: String
) {
withContext(Dispatchers.IO) {
try {
val shareDir = File(appContext.cacheDir, "shared_files")
if (shareDir.exists()) {
shareDir.listFiles()?.forEach { file ->
try {
file.delete()
} catch (_: Exception) {
Timber.w("Failed to delete temp share file: ${file.name}")
}
}
} else {
shareDir.mkdirs()
}
val destFile = File(shareDir, filename)
FileOutputStream(destFile).use { output ->
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
input.copyTo(output)
} ?: error("Could not open source file.")
}
val authority = "${appContext.packageName}.provider"
val contentUri = androidx.core.content.FileProvider.getUriForFile(
appContext, authority, destFile
)
val mimeType = SharedFileCapabilities.mimeTypeFor(fileType) ?: "application/octet-stream"
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, contentUri)
putExtra(Intent.EXTRA_TITLE, filename)
putExtra(Intent.EXTRA_SUBJECT, appContext.getString(R.string.share_subject, filename))
clipData = ClipData.newRawUri(filename, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(shareIntent, appContext.getString(R.string.share_file_chooser_title))
if (activityContext !is android.app.Activity) {
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
withContext(Dispatchers.Main) { activityContext.startActivity(chooser) }
} catch (e: Exception) {
Timber.e(e, "Share original file failed")
showBanner(appContext.getString(R.string.error_share_failed, e.localizedMessage ?: e.message.orEmpty()), isError = true)
}
}
}
private fun copyUriBytes(sourceUri: Uri, destUri: Uri) {
val contentResolver = appContext.contentResolver
contentResolver.openInputStream(sourceUri)?.use { input ->
contentResolver.openOutputStream(destUri)?.use { output ->
input.copyTo(output)
} ?: error("Could not open destination file.")
} ?: error("Could not open source file.")
}
private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) {
if (!uiState.value.isSyncEnabled) return
cloudMetadataUploadJobs.remove(bookId)?.cancel()
@ -2761,6 +2872,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val closingBookId = _internalState.value.selectedBookId
val uriString = _internalState.value.selectedPdfUri?.toString()
?: _internalState.value.selectedEpubUri?.toString()
val isTemporaryExternalSession = closingBookId != null && closingBookId == temporaryExternalSessionBookId
logCloudSyncTrace {
"android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}"
}
@ -2787,6 +2899,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
selectedEpubBook = null,
selectedFileType = null,
isLoading = false,
isTemporaryExternalOpen = false,
errorMessage = null,
initialLocator = null,
initialPageInBook = null,
@ -2794,20 +2907,40 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isOpeningFromTtsNotification = false
)
}
clearPersistedReaderSession()
if (!isTemporaryExternalSession) {
clearPersistedReaderSession()
}
var removesExternalFileOnClose = false
if (closingBookId != null && closingBookId == externalOpenedBookId) {
val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK"
if (closingBookId != null && (closingBookId == externalOpenedBookId || isTemporaryExternalSession)) {
val behavior = if (isTemporaryExternalSession) {
EXTERNAL_FILE_BEHAVIOR_TEMPORARY
} else {
prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, EXTERNAL_FILE_BEHAVIOR_ASK) ?: EXTERNAL_FILE_BEHAVIOR_ASK
}
if (behavior == "ASK") {
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
} else if (behavior == "DELETE") {
removesExternalFileOnClose = true
deletePendingExternalFileRemoval(closingBookId, uriString)
} else if (behavior == EXTERNAL_FILE_BEHAVIOR_TEMPORARY) {
removesExternalFileOnClose = true
val shouldDeleteImportedCopy = closingBookId == externalOpenedBookId
if (shouldDeleteImportedCopy) {
viewModelScope.launch {
deletePendingExternalFileRemoval(PendingExternalFileRemoval(closingBookId, uriString))
_temporaryExternalOpenFinished.send(Unit)
}
} else {
viewModelScope.launch {
_temporaryExternalOpenFinished.send(Unit)
}
}
} else {
clearPendingExternalFileRemovals(setOf(closingBookId))
}
externalOpenedBookId = null
temporaryExternalSessionBookId = null
}
if (uriString != null && !removesExternalFileOnClose) {
@ -4802,7 +4935,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) {
fun onFileSelected(
uri: Uri,
isFromRecent: Boolean = false,
isExternalIntent: Boolean = false,
isTemporaryExternalIntent: Boolean = false
) {
if (isFromRecent) {
Timber.i("Opening recent file: $uri")
viewModelScope.launch {
@ -4815,7 +4953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
} else {
Timber.i("Importing new file: $uri")
importExternalFile(uri, isExternalIntent)
importExternalFile(uri, isExternalIntent, isTemporaryExternalIntent)
}
}
@ -4865,9 +5003,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) {
private fun importExternalFile(
externalUri: Uri,
isExternalIntent: Boolean = false,
isTemporaryExternalIntent: Boolean = false
) {
if (isTemporaryExternalIntent) {
openTemporaryExternalFile(externalUri)
return
}
_internalState.update {
it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
it.copy(
isLoading = true,
errorMessage = null,
contextualActionItems = emptySet()
)
}
viewModelScope.launch {
@ -4877,10 +5028,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (importResult != null) {
val (internalUri, bookId, type) = importResult
if (isExternalIntent) {
externalOpenedBookId = bookId
if (prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") == "DELETE") {
markPendingExternalFileRemoval(bookId, internalUri.toString())
}
trackExternalOpenForClose(
bookId = bookId,
importedCopyUriString = internalUri.toString(),
isTemporaryExternalIntent = isTemporaryExternalIntent
)
}
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
openBook(
@ -4895,6 +5047,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val existingItem = recentFilesRepository.getFileByBookId(hash)
if (existingItem != null) {
Timber.i("Re-selected an existing book. Opening it.")
if (isTemporaryExternalIntent) {
trackExternalOpenForClose(
bookId = existingItem.bookId,
importedCopyUriString = null,
isTemporaryExternalIntent = true
)
}
onRecentFileClicked(existingItem)
_internalState.update { it.copy(isLoading = false) }
return@launch
@ -4928,6 +5087,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private fun openTemporaryExternalFile(externalUri: Uri) {
_internalState.update {
it.copy(
isLoading = true,
isTemporaryExternalOpen = true,
errorMessage = null,
contextualActionItems = emptySet()
)
}
viewModelScope.launch {
val type = getFileTypeFromUri(externalUri, appContext)
if (type == null) {
_internalState.update {
it.copy(
isLoading = false,
isTemporaryExternalOpen = false,
errorMessage = appContext.getString(R.string.error_unsupported_file_type)
)
}
return@launch
}
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Temporary File"
val bookId = "temporary-${UUID.randomUUID()}"
trackExternalOpenForClose(
bookId = bookId,
importedCopyUriString = null,
isTemporaryExternalIntent = true
)
openBook(
uri = externalUri,
bookId = bookId,
type = type,
originalDisplayName = displayName,
persistToLibrary = false
)
}
}
fun saveHighlights(bookId: String, highlightsJson: String) {
viewModelScope.launch {
val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri
@ -5154,7 +5352,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private suspend fun cleanupBookDataLocally(bookId: String) {
protected open suspend fun cleanupBookDataLocally(bookId: String) {
pdfTextRepository.clearBookText(bookId)
clearImportedFileCache(bookId)
bookCacheDao.deleteEntireBookCache(bookId)
@ -5199,7 +5397,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isInitialPageExplicit: Boolean = false,
initialLocatorOverride: Locator? = null,
initialCfiOverride: String? = null,
preserveTtsOnOpen: Boolean = false
preserveTtsOnOpen: Boolean = false,
persistToLibrary: Boolean = true
) {
val openBookStartTime = System.currentTimeMillis()
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
@ -5293,16 +5492,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
persistReaderSession(bookId, type)
addFileToRecent(
uri,
type,
bookId,
customDisplayName = originalDisplayName,
isRecent = true,
sourceFolderUri = null,
bundleResult = bundleResult
)
if (persistToLibrary) {
persistReaderSession(bookId, type)
addFileToRecent(
uri,
type,
bookId,
customDisplayName = originalDisplayName,
isRecent = true,
sourceFolderUri = null,
bundleResult = bundleResult
)
}
if (!suppressNavigation) {
Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event")
@ -5343,7 +5544,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
persistReaderSession(bookId, type)
if (persistToLibrary) {
persistReaderSession(bookId, type)
}
if (!suppressNavigation) {
Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event")
@ -5352,22 +5555,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when (type) {
FileType.EPUB -> {
loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.MOBI -> {
loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.FB2 -> {
loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult)
loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
FileType.ODT, FileType.FODT -> {
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult)
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary)
}
else -> {
loadSingleFile(
uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult
uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult, persistToLibrary = persistToLibrary
)
}
}
@ -5390,7 +5593,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
private fun loadFb2(
uri: Uri,
bookId: String,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START")
viewModelScope.launch {
@ -5412,9 +5621,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("FB2 parsing successful. Title: ${fb2Book.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
addFileToRecent(
uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
)
if (persistToLibrary) {
addFileToRecent(
uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
)
}
_internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) }
} catch (e: Exception) {
@ -5426,7 +5637,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
private fun loadOdt(
uri: Uri,
bookId: String,
isFlat: Boolean,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
viewModelScope.launch {
@ -5449,9 +5667,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.i("ODT parsing successful. Title: ${odtBook.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
addFileToRecent(
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
)
if (persistToLibrary) {
addFileToRecent(
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult
)
}
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
} catch (e: Exception) {
@ -5468,7 +5688,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bookId: String,
type: FileType,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type")
@ -5506,16 +5727,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
Timber.i("Import successful ($type). Title: ${epubBook.title}")
addFileToRecent(
uri,
type,
bookId,
epubBook,
customDisplayName,
isRecent = true,
sourceFolderUri = null,
bundleResult = bundleResult
)
if (persistToLibrary) {
addFileToRecent(
uri,
type,
bookId,
epubBook,
customDisplayName,
isRecent = true,
sourceFolderUri = null,
bundleResult = bundleResult
)
}
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf")
@ -5554,7 +5777,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return resolveFileTypeFromMetadata(fileName, mimeType)
}
private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
private fun loadMobi(
uri: Uri,
bookId: String,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
viewModelScope.launch {
if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
@ -5576,16 +5805,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (mobiAsEpubBook != null) {
Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}")
addFileToRecent(
uri,
FileType.MOBI,
bookId,
mobiAsEpubBook,
customDisplayName,
isRecent = true,
sourceFolderUri = null,
bundleResult = bundleResult
)
if (persistToLibrary) {
addFileToRecent(
uri,
FileType.MOBI,
bookId,
mobiAsEpubBook,
customDisplayName,
isRecent = true,
sourceFolderUri = null,
bundleResult = bundleResult
)
}
_internalState.update {
it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false)
}
@ -5607,7 +5838,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) {
private fun loadEpub(
uri: Uri,
bookId: String,
customDisplayName: String? = null,
bundleResult: CalibreBundleResult? = null,
persistToLibrary: Boolean = true
) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START")
viewModelScope.launch {
@ -5633,16 +5870,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
addFileToRecent(
uri,
FileType.EPUB,
bookId,
epubBook,
customDisplayName,
isRecent = true,
sourceFolderUri = null,
bundleResult = bundleResult
)
if (persistToLibrary) {
addFileToRecent(
uri,
FileType.EPUB,
bookId,
epubBook,
customDisplayName,
isRecent = true,
sourceFolderUri = null,
bundleResult = bundleResult
)
}
_internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) }
Timber.tag("FileOpenPerf")
@ -7081,6 +7320,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id"
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
private const val EXTERNAL_FILE_BEHAVIOR_ASK = "ASK"
private const val EXTERNAL_FILE_BEHAVIOR_TEMPORARY = "TEMPORARY"
private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals"
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"

View file

@ -2,7 +2,10 @@ package com.aryan.reader
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
import androidx.compose.runtime.setValue
import com.aryan.reader.data.RecentFileItem
@Composable
@ -64,28 +67,17 @@ internal fun ReaderFileInfoDialogs(
}
}
item?.let { fileInfoItem ->
if (isFileInfoVisible) {
FileInfoDialog(
item = fileInfoItem,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onFileInfoVisibleChange(false) },
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(fileInfoItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
},
onOpenTags = {
onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
}
)
HydratedFileInfoDialog(
item = item,
isVisible = isFileInfoVisible,
uiState = uiState,
viewModel = viewModel,
onDismiss = { onFileInfoVisibleChange(false) },
onOpenTags = { bookId ->
onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(bookId))
}
}
)
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
TagSelectionBottomSheet(
@ -102,3 +94,49 @@ internal fun ReaderFileInfoDialogs(
)
}
}
@Composable
internal fun HydratedFileInfoDialog(
item: RecentFileItem?,
isVisible: Boolean,
uiState: ReaderScreenState,
viewModel: MainViewModel,
onDismiss: () -> Unit,
onOpenTags: (String) -> Unit
) {
var fileInfoItem by remember(item?.bookId) { mutableStateOf(item) }
var hasResolvedFullItem by remember(item?.bookId) { mutableStateOf(false) }
LaunchedEffect(item) {
fileInfoItem = item
hasResolvedFullItem = false
}
LaunchedEffect(isVisible, item?.bookId) {
if (isVisible && item != null) {
fileInfoItem = viewModel.getFileInfoItem(item.bookId)?.copy(tags = item.tags) ?: item
hasResolvedFullItem = true
}
}
val resolvedItem = fileInfoItem
if (isVisible && resolvedItem != null && hasResolvedFullItem) {
FileInfoDialog(
item = resolvedItem,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = onDismiss,
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(resolvedItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(resolvedItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(resolvedItem.bookId)
},
onOpenTags = {
onOpenTags(resolvedItem.bookId)
}
)
}
}

View file

@ -0,0 +1,15 @@
package com.aryan.reader
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.supportsVariableWeightAxis
const val ReaderFontDiagnosticsTag = "ReaderFontDiag"
fun readerFontDiagnosticSummary(nameWithoutExtension: String): String {
val variant = nameWithoutExtension.detectFontVariant()
return "name='$nameWithoutExtension' " +
"signature='${nameWithoutExtension.familyFilenameSignature()}' " +
"variant=$variant " +
"variableWght=${nameWithoutExtension.supportsVariableWeightAxis()}"
}

View file

@ -0,0 +1,19 @@
package com.aryan.reader
import kotlin.math.roundToInt
fun readerModalMaxHeightDp(
screenHeightDp: Int,
fraction: Float = 0.85f,
verticalMarginDp: Int = 32,
preferredMinHeightDp: Int = 220
): Int {
val usableHeight = (screenHeightDp - verticalMarginDp).coerceAtLeast(1)
val proportionalHeight = (screenHeightDp * fraction).roundToInt().coerceAtLeast(1)
val cappedHeight = minOf(usableHeight, proportionalHeight)
return if (usableHeight >= preferredMinHeightDp) {
cappedHeight.coerceAtLeast(preferredMinHeightDp)
} else {
usableHeight
}
}

View file

@ -87,6 +87,7 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Policy
@ -142,6 +143,7 @@ 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.SharedFileCapabilities
import com.aryan.reader.shared.SharedLegalLinks
import com.aryan.reader.shared.SharedLegalProfile
import com.aryan.reader.data.BookMetadataEdit
@ -274,6 +276,8 @@ fun ContextualTopAppBar(
selectedItemCount: Int,
onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null,
onSaveClick: (() -> Unit)? = null,
onShareClick: (() -> Unit)? = null,
onTagClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null,
@ -302,6 +306,16 @@ fun ContextualTopAppBar(
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
}
}
if (selectedItemCount == 1 && onSaveClick != null) {
IconButton(onClick = onSaveClick) {
Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save_copy_to_device))
}
}
if (selectedItemCount == 1 && onShareClick != null) {
IconButton(onClick = onShareClick) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.action_share))
}
}
if (onSelectAllClick != null) {
IconButton(onClick = onSelectAllClick) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
@ -1584,6 +1598,31 @@ fun RecentFileItem.isOpdsStream(): Boolean {
return uriString?.startsWith("opds-pse://") == true
}
fun RecentFileItem.canExportOriginalFile(): Boolean {
return uriString != null && !isOpdsStream()
}
fun RecentFileItem.suggestedOriginalFileName(): String {
val fallbackExtension = SharedFileCapabilities.primaryExtensionFor(type)
val baseName = displayName
.takeIf { it.isNotBlank() }
?: title?.takeIf { it.isNotBlank() }
?: "book"
val sanitized = baseName
.replace(Regex("""[\\/:*?"<>|]+"""), "_")
.trim()
.take(120)
.ifBlank { "book" }
return if (
fallbackExtension != null &&
!sanitized.endsWith(".$fallbackExtension", ignoreCase = true)
) {
"$sanitized.$fallbackExtension"
} else {
sanitized
}
}
@Composable
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
val container = if (overlay) {

View file

@ -67,7 +67,8 @@ val supportedAppLanguageOptions = listOf(
"中文",
"简体中文",
)
)
),
AppLanguageOption("et", R.string.language_estonian, listOf("estonian", "eesti"))
)
val appLanguageSelectionOptions = listOf(systemAppLanguageOption) + supportedAppLanguageOptions

View file

@ -25,12 +25,15 @@ import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.readerFontDiagnosticSummary
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
private const val FONTS_DIR = "custom_fonts"
private const val MAX_IMPORTED_FONT_BASENAME_LENGTH = 120
class FontsRepository(private val context: Context) {
private val fontDao = AppDatabase.getDatabase(context).customFontDao()
@ -73,14 +76,23 @@ class FontsRepository(private val context: Context) {
val contentResolver = context.contentResolver
val originalName = getFileName(uri) ?: "unknown.ttf"
val extension = originalName.substringAfterLast('.', "").lowercase()
val displayName = originalName.substringBeforeLast('.')
Timber.tag(ReaderFontDiagnosticsTag).i(
"import.start originalName='$originalName' extension='$extension' " +
readerFontDiagnosticSummary(displayName)
)
if (extension !in listOf("ttf", "otf", "woff2")) {
Timber.tag(ReaderFontDiagnosticsTag).w(
"import.unsupported originalName='$originalName' extension='$extension'"
)
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
}
val fontId = UUID.randomUUID().toString()
val internalFileName = "font_${fontId}.$extension"
val destinationFile = File(fontsDir, internalFileName)
val destinationFile = uniqueImportedFontFile(displayName, extension, fontId)
val internalFileName = destinationFile.name
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destinationFile).use { output ->
@ -88,8 +100,6 @@ class FontsRepository(private val context: Context) {
}
}
val displayName = originalName.substringBeforeLast('.')
val entity = CustomFontEntity(
id = fontId,
displayName = displayName,
@ -100,10 +110,16 @@ class FontsRepository(private val context: Context) {
)
fontDao.insertFont(entity)
Timber.tag(ReaderFontDiagnosticsTag).i(
"import.saved displayName='$displayName' internalFileName='$internalFileName' " +
"exists=${destinationFile.exists()} bytes=${destinationFile.length()} " +
"path='${destinationFile.absolutePath}'"
)
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
Result.success(entity)
} catch (e: Exception) {
Timber.tag(ReaderFontDiagnosticsTag).e(e, "import.failed uri='$uri'")
Timber.e(e, "Failed to import font")
Result.failure(e)
}
@ -130,6 +146,18 @@ class FontsRepository(private val context: Context) {
fontDao.deletePermanently(fontId)
}
private fun uniqueImportedFontFile(displayName: String, extension: String, fontId: String): File {
val preferredFileName = importedFontFileName(displayName, extension)
val preferredFile = File(fontsDir, preferredFileName)
if (!preferredFile.exists()) return preferredFile
val fallbackFileName = importedFontFileName(
displayName = "${displayName}_${fontId.take(8)}",
extension = extension
)
return File(fontsDir, fallbackFileName)
}
private fun getFileName(uri: Uri): String? {
var result: String? = null
if (uri.scheme == "content") {
@ -152,4 +180,18 @@ class FontsRepository(private val context: Context) {
}
return result
}
}
}
internal fun importedFontFileName(displayName: String, extension: String): String {
val safeBaseName = displayName
.replace(Regex("""[\\/:*?"<>|\p{Cntrl}]"""), "_")
.replace(Regex("""\s+"""), " ")
.trim(' ', '.')
.take(MAX_IMPORTED_FONT_BASENAME_LENGTH)
.ifBlank { "font" }
val safeExtension = extension
.lowercase()
.replace(Regex("""[^a-z0-9]"""), "")
.ifBlank { "ttf" }
return "$safeBaseName.$safeExtension"
}

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, readingPositionModifiedTimestamp 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, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS 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, readingPositionModifiedTimestamp 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, substr(description, 1, 4096) AS description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, substr(originalDescription, 1, 4096) AS 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)")

View file

@ -457,19 +457,17 @@ class EpubParser(private val context: Context) {
var pageTargets: List<EpubPageTarget> = emptyList()
val ncxMetadataMap = mutableMapOf<String, NcxMetadata>()
val extractionRoot = File(extractionBasePath)
val tocFileItem = if (shouldUseToc) resolveTocFileItem(document.spine, manifestItems) else null
val tocDocumentNode = tocFileItem?.let { item ->
val ncxData = filesContentMap[item.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, item.absPath).takeIf { it.exists() }?.readBytes()
ncxData?.let { parseXMLFile(it) }
}
val ncxParentDir = tocFileItem?.let { File(it.absPath).parentFile ?: File("") }
if (shouldUseToc) {
Timber.d("shouldUseToc is true. Attempting to parse NCX.")
val tocFileItem = manifestItems.values.firstOrNull {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
if (tocFileItem != null) {
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
if (tocFileItem != null && ncxParentDir != null) {
if (tocDocumentNode != null) {
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
@ -495,15 +493,8 @@ class EpubParser(private val context: Context) {
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
val tableOfContents = if (shouldUseToc) {
val tocFileItem = manifestItems.values.firstOrNull {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
if (tocFileItem != null) {
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element?
if (tocDocumentNode != null && ncxParentDir != null) {
val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element?
if (navMapElement != null) {
parseTableOfContents(navMapElement, ncxParentDir)
@ -592,6 +583,21 @@ class EpubParser(private val context: Context) {
return result
}
private fun resolveTocFileItem(
spine: Node,
manifestItems: Map<String, EpubManifestItem>
): EpubManifestItem? {
spine.getAttributeValue("toc")
?.takeIf { it.isNotBlank() }
?.let { tocId -> manifestItems[tocId] }
?.let { return it }
return manifestItems.values.firstOrNull {
it.mediaType.equals("application/x-dtbncx+xml", ignoreCase = true)
} ?: manifestItems.values.firstOrNull {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
}
@Throws(EpubParserException::class)
private fun createEpubDocument(files: Map<String, EpubFile>): EpubDocument {

View file

@ -41,7 +41,7 @@ class OdtParser(private val context: Context) {
val mathJaxFileName = "tex-mml-chtml.js"
val mathJaxFile = File(extractionDir, mathJaxFileName)
if (!mathJaxFile.exists()) {
if (parseContent && !mathJaxFile.exists()) {
try {
context.assets.open("mathjax/$mathJaxFileName").use { input ->
FileOutputStream(mathJaxFile).use { output ->
@ -170,32 +170,33 @@ class OdtParser(private val context: Context) {
try {
if (!isFlat) {
val zis = ZipInputStream(inputStream)
var entry = zis.nextEntry
var contentXmlBytes: ByteArray? = null
var stylesXmlBytes: ByteArray? = null
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
while (entry != null) {
if (!entry.isDirectory) {
when (entry.name) {
"content.xml" -> contentXmlBytes = zis.readBytes()
"styles.xml" -> stylesXmlBytes = zis.readBytes()
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
else -> {
if (entry.name !in ignoredFiles) {
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}")
ZipInputStream(inputStream).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
when (entry.name) {
"content.xml" -> contentXmlBytes = zis.readBytes()
"styles.xml" -> stylesXmlBytes = zis.readBytes()
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
else -> {
if (entry.name !in ignoredFiles) {
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}")
}
}
}
}
}
entry = zis.nextEntry
}
entry = zis.nextEntry
}
// Pre-parse styles if available

View file

@ -22,8 +22,6 @@ package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Color
@ -38,8 +36,6 @@ import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -52,8 +48,10 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@ -76,6 +74,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
@ -86,7 +85,13 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
import com.aryan.reader.R
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.copyPlainTextToClipboard
import com.aryan.reader.getReaderTextureDataUri
import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.fontWeightCssDescriptor
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
@ -96,6 +101,7 @@ import kotlinx.coroutines.launch
import org.json.JSONObject
import timber.log.Timber
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
@ -202,6 +208,49 @@ private fun getFontCssInjection(): String {
""".trimIndent()
}
private fun buildCustomFontCssForWebView(customFontPath: String?, phase: String): String {
if (customFontPath == null) return ""
val fontFile = File(customFontPath)
val signature = fontFile.nameWithoutExtension.familyFilenameSignature()
val siblings = fontFile.parentFile?.listFiles()?.filter {
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
it.nameWithoutExtension.familyFilenameSignature() == signature
} ?: listOf(fontFile)
Timber.tag(ReaderFontDiagnosticsTag).i(
"webview.$phase.customCss.start basePath='${fontFile.absolutePath}' " +
"exists=${fontFile.exists()} bytes=${fontFile.length()} " +
readerFontDiagnosticSummary(fontFile.nameWithoutExtension) +
" siblings=${siblings.joinToString { it.name }}"
)
val css = siblings.mapNotNull { sibling ->
val variant = sibling.nameWithoutExtension.detectFontVariant()
if (variant == null) {
Timber.tag(ReaderFontDiagnosticsTag).w(
"webview.$phase.customCss.skipNoVariant file='${sibling.name}' " +
readerFontDiagnosticSummary(sibling.nameWithoutExtension)
)
return@mapNotNull null
}
val weight = sibling.nameWithoutExtension.fontWeightCssDescriptor(variant.weight)
val style = if (variant.style == FontStyle.Italic) "italic" else "normal"
Timber.tag(ReaderFontDiagnosticsTag).i(
"webview.$phase.customCss.face file='${sibling.name}' fontWeight='$weight' fontStyle='$style' " +
readerFontDiagnosticSummary(sibling.nameWithoutExtension)
)
"@font-face { font-family: 'CustomFont'; src: url('file://${sibling.absolutePath}'); font-weight: $weight; font-style: $style; }"
}.joinToString(" ")
val faceCount = Regex("@font-face").findAll(css).count()
Timber.tag(ReaderFontDiagnosticsTag).i(
"webview.$phase.customCss.done faceCount=$faceCount cssLength=${css.length}"
)
return css
}
private fun getJsToInject(context: Context): String {
return try {
context.assets.open("epub_reader.js").use { inputStream ->
@ -514,6 +563,7 @@ fun ChapterWebView(
onFootnoteRequested: (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
epubFontFaceCss: String = "",
currentTextAlign: ReaderTextAlign,
onHighlightClicked: () -> Unit,
onAutoScrollChapterEnd: () -> Unit = {},
@ -578,10 +628,14 @@ fun ChapterWebView(
showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_open)) }
TextButton(onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
clipboard.setPrimaryClip(clip)
val copied = copyPlainTextToClipboard(
context = context,
label = context.getString(R.string.clip_label_copied_link),
text = urlToShow
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_copy)) }
}
@ -927,13 +981,14 @@ fun ChapterWebView(
)
val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) {
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
} else ""
val combinedCss = "$fontCss $customFontCss"
val customFontCss = buildCustomFontCssForWebView(customFontPath, "initial")
val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
.filter { it.isNotBlank() }
.joinToString(separator = " ")
val escapedCombinedCss = escapeJsString(combinedCss)
val injectFontJs =
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);"
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$escapedCombinedCss\"; document.head.appendChild(style);"
view?.evaluateJavascript("javascript:$injectFontJs") {
Timber.d("CSS Injection result: $it")
}
@ -1125,10 +1180,10 @@ fun ChapterWebView(
runtimeApplierState.logPending(chapterTitle)
} else {
val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) {
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
} else ""
val combinedCss = "$fontCss $customFontCss"
val customFontCss = buildCustomFontCssForWebView(customFontPath, "runtime")
val combinedCss = listOf(fontCss, customFontCss, epubFontFaceCss)
.filter { it.isNotBlank() }
.joinToString(separator = " ")
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
@ -1166,8 +1221,9 @@ fun ChapterWebView(
if (fontCssChanged) {
runtimeApplierState.fontCss = combinedCss
val escapedCombinedCss = escapeJsString(combinedCss)
val injectFontJs =
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$escapedCombinedCss\";"
webView.evaluateJavascript("javascript:$injectFontJs", null)
}
@ -1304,10 +1360,14 @@ fun ChapterWebView(
) {
PaginatedTextSelectionMenu(
onCopy = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), state.selectedText)
clipboard.setPrimaryClip(clip)
val copied = copyPlainTextToClipboard(
context = context,
label = context.getString(R.string.clip_label_copied_text),
text = state.selectedText
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript(

View file

@ -4,7 +4,10 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@ -20,12 +23,14 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -35,14 +40,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap
import com.aryan.reader.R
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.readerModalMaxHeightDp
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class)
@ -65,25 +71,28 @@ fun DictionarySettingsDialog(
val context = LocalContext.current
var dictionaryApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
var searchApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val configuration = LocalConfiguration.current
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
LaunchedEffect(Unit) {
dictionaryApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
searchApps = ExternalDictionaryHelper.getAvailableSearchApps(context)
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
modifier = Modifier.fillMaxWidth()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
Text(
text = stringResource(R.string.dict_lookup_settings),
style = MaterialTheme.typography.headlineSmall,
@ -212,7 +221,6 @@ fun DictionarySettingsDialog(
onSelect = onSelectSearchPackage,
placeholder = stringResource(R.string.dict_select_app)
)
}
}
}
}

View file

@ -27,8 +27,6 @@ package com.aryan.reader.epubreader
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioManager
@ -140,6 +138,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@ -154,6 +153,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig
import com.aryan.reader.copyPlainTextToClipboard
import com.aryan.reader.BookWordReplacementsSheet
import com.aryan.reader.BuiltInThemes
import com.aryan.reader.MainViewModel
@ -191,6 +191,7 @@ import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.readerSliderBookmarkPosition
import com.aryan.reader.readerSliderChromeColors
import com.aryan.reader.readerSliderToggleState
import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator
@ -204,7 +205,10 @@ import com.aryan.reader.paginatedreader.ParagraphBlock
import com.aryan.reader.paginatedreader.QuoteBlock
import com.aryan.reader.paginatedreader.TextContentBlock
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.paginatedreader.buildEpubFontFaceCss
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.locatorForPersistence
import com.aryan.reader.paginatedreader.nativeVerticalChapterPageInfo
import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
@ -243,6 +247,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import org.jsoup.Jsoup
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
@ -1127,6 +1132,20 @@ fun EpubReaderHost(
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
val epubFontFaceCss = remember(epubBook.css, epubBook.extractionBasePath) {
val fontFaces = epubBook.css.flatMap { (path, content) ->
CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 1, maxHeight = 1),
isDarkTheme = false,
adaptThemeColors = false
).fontFaces
}
buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
}
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf(initialCfi) }
@ -1175,7 +1194,7 @@ fun EpubReaderHost(
fun currentNativeVerticalLocator(): Locator? {
val bookPaginator = paginator as? BookPaginator
val pageChapterIndex = bookPaginator?.findChapterIndexForPage(nativeVerticalCurrentPage)
return nativeVerticalLocation?.locator
return nativeVerticalLocation?.locatorForPersistence()
?: lastKnownLocator?.takeIf { pageChapterIndex == null || it.chapterIndex == pageChapterIndex }
?: bookPaginator?.getLocatorForPage(nativeVerticalCurrentPage)
}
@ -1187,6 +1206,8 @@ fun EpubReaderHost(
keepVisible: Boolean = false
) {
if (locator != null) {
nativeVerticalScrollRequest = null
nativeVerticalProgressScrollRequest = null
nativeVerticalLocatorScrollRequest = locator
nativeVerticalLocatorScrollRequestId += 1L
nativeVerticalLocatorScrollKeepVisible = keepVisible
@ -2110,6 +2131,44 @@ fun EpubReaderHost(
}
}
val nativeVerticalDisplayPageInfo = remember(
isNativeVerticalMode,
nativeVerticalLocation,
nativeVerticalCurrentPage,
nativeVerticalTotalPages,
currentChapterIndex,
lastKnownLocator,
paginator
) {
if (!isNativeVerticalMode) {
null
} else {
nativeVerticalLocation?.chapterPageInfo ?: run {
val bookPaginator = paginator as? BookPaginator
val locationLocator = nativeVerticalLocation?.locator
val chapterIndex = nativeVerticalLocation?.chapterIndex
?: locationLocator?.chapterIndex
?: lastKnownLocator?.chapterIndex
?: currentChapterIndex
val locatorForChapter = locationLocator
?.takeIf { it.chapterIndex == chapterIndex }
?: lastKnownLocator?.takeIf { it.chapterIndex == chapterIndex }
val chapterLengthChars = chapters
.getOrNull(chapterIndex)
?.plainTextCharacterCount()
?: 0
nativeVerticalChapterPageInfo(
chapterCharOffset = locatorForChapter?.charOffset,
chapterLengthChars = chapterLengthChars,
chapterPageCount = bookPaginator?.chapterPageCounts?.get(chapterIndex),
compatPageIndex = nativeVerticalCurrentPage,
chapterStartPageIndex = bookPaginator?.chapterStartPageIndices?.get(chapterIndex)
)
}
}
}
fun currentEpubSliderPage(): Int {
return when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> if (isNativeVerticalMode) {
@ -4280,6 +4339,74 @@ fun EpubReaderHost(
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
val keyboardLineScrollPx = with(density) {
(configuration.screenHeightDp.dp.toPx() * 0.16f).roundToInt().coerceAtLeast(96)
}
val keyboardPageScrollPx = with(density) {
(configuration.screenHeightDp.dp.toPx() * 0.82f).roundToInt().coerceAtLeast(keyboardLineScrollPx)
}
fun scrollVerticalReaderBy(deltaPx: Int) {
if (isNativeVerticalMode) {
nativeVerticalScrollDeltaRequestId += 1L
nativeVerticalScrollDeltaAnimated = false
nativeVerticalScrollDeltaRequest = deltaPx.toFloat()
} else {
webViewRefForTts?.evaluateJavascript(
"window.scrollBy({ top: $deltaPx, behavior: 'smooth' });",
null
)
}
}
fun navigateReaderPage(targetPage: Int) {
when {
isNativeVerticalMode -> {
val lastPage = (nativeVerticalTotalPages - 1).coerceAtLeast(0)
nativeVerticalScrollRequest = targetPage.coerceIn(0, lastPage)
}
currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
scrollVerticalReaderBy((targetPage - nativeVerticalCurrentPage).coerceIn(-1, 1) * keyboardPageScrollPx)
}
else -> {
scope.launch {
val pageCount = paginatedPagerState.pageCount
if (pageCount <= 0) return@launch
val page = targetPage.coerceIn(0, pageCount - 1)
if (page != paginatedPagerState.currentPage) {
if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(page, animationSpec = tween(700))
} else {
paginatedPagerState.scrollToPage(page)
}
}
}
}
}
}
fun navigateReaderPageBy(delta: Int) {
when {
isNativeVerticalMode -> navigateReaderPage(nativeVerticalCurrentPage + delta)
currentRenderMode == RenderMode.VERTICAL_SCROLL -> scrollVerticalReaderBy(delta * keyboardPageScrollPx)
else -> navigateReaderPage(paginatedPagerState.currentPage + delta)
}
}
fun navigateReaderBoundary(first: Boolean) {
when {
isNativeVerticalMode -> navigateReaderPage(if (first) 0 else nativeVerticalTotalPages - 1)
currentRenderMode == RenderMode.VERTICAL_SCROLL -> {
val script = if (first) {
"window.scrollTo({ top: 0, behavior: 'smooth' });"
} else {
"window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });"
}
webViewRefForTts?.evaluateJavascript(script, null)
}
else -> navigateReaderPage(if (first) 0 else paginatedPagerState.pageCount - 1)
}
}
Box(
modifier = Modifier
@ -4363,6 +4490,17 @@ fun EpubReaderHost(
}
}
)
.epubReaderKeyboardNavigationHandler(
enabled = !searchState.isSearchActive,
renderMode = currentRenderMode,
isRightToLeftPagination = rightToLeftPagination,
verticalLineScrollPx = keyboardLineScrollPx,
onVerticalScrollBy = ::scrollVerticalReaderBy,
onNextPage = { navigateReaderPageBy(1) },
onPreviousPage = { navigateReaderPageBy(-1) },
onFirstPage = { navigateReaderBoundary(first = true) },
onLastPage = { navigateReaderBoundary(first = false) }
)
) {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
@ -4460,6 +4598,7 @@ fun EpubReaderHost(
},
onLocationChanged = { location ->
nativeVerticalLocation = location
location.locatorForPersistence()?.let { lastKnownLocator = it }
},
onTap = {
focusManager.clearFocus()
@ -4613,6 +4752,27 @@ fun EpubReaderHost(
""".trimIndent()
val chapterToRender = chapters[targetChapterIndex]
val chapterFontFaceCss = remember(
chapterHead,
chapterToRender.absPath,
epubBook.extractionBasePath
) {
val fontFaces = Jsoup.parse("<head>$chapterHead</head>")
.head()
.getElementsByTag("style")
.flatMap { styleElement ->
CssParser.parse(
cssContent = styleElement.data(),
cssPath = chapterToRender.absPath,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 1, maxHeight = 1),
isDarkTheme = false,
adaptThemeColors = false
).fontFaces
}
buildEpubFontFaceCss(fontFaces, epubBook.extractionBasePath)
}
fun isCurrentRenderedChapter(): Boolean =
targetChapterIndex == currentChapterIndex
@ -4975,6 +5135,9 @@ fun EpubReaderHost(
currentVerticalMargin = currentVerticalMargin,
currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath,
epubFontFaceCss = listOf(epubFontFaceCss, chapterFontFaceCss)
.filter { it.isNotBlank() }
.joinToString(separator = " "),
currentTextAlign = currentTextAlign,
activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
@ -6057,8 +6220,8 @@ fun EpubReaderHost(
?: "Chapter"
val displayPageInfo = when {
isNativeVerticalMode && nativeVerticalTotalPages > 0 ->
" (${nativeVerticalCurrentPage + 1}/$nativeVerticalTotalPages)"
isNativeVerticalMode && nativeVerticalDisplayPageInfo != null ->
" (${nativeVerticalDisplayPageInfo.currentPage}/${nativeVerticalDisplayPageInfo.totalPages})"
currentScrollHeightValue <= 0 || isChapterParsing -> ""
else -> " ($currentPageInChapter/$totalPagesInCurrentChapter)"
}
@ -7059,9 +7222,14 @@ fun EpubReaderHost(
highlightToNoteCfi = null
},
onCopy = {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), targetHighlight.text)
clipboardManager.setPrimaryClip(clip)
val copied = copyPlainTextToClipboard(
context = context,
label = context.getString(R.string.clip_label_copied_text),
text = targetHighlight.text
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
highlightToNoteCfi = null
},
onDictionary = {

View file

@ -107,8 +107,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import com.aryan.reader.R
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.readerModalMaxHeightDp
import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.supportedFontMimeTypes
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.fontFaceSummary
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.groupByFamily
import com.aryan.reader.shared.hasVariableWeightFace
import com.aryan.reader.shared.supportsVariableWeightAxis
import timber.log.Timber
import java.io.File
import kotlin.math.roundToInt
@ -412,8 +423,64 @@ fun getComposeFontFamily(
): FontFamily {
if (customFontPath != null) {
return try {
FontFamily(Font(File(customFontPath)))
} catch (_: Exception) {
val baseFile = File(customFontPath)
val signature = baseFile.nameWithoutExtension.familyFilenameSignature()
val siblings = baseFile.parentFile?.listFiles()?.filter {
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
it.nameWithoutExtension.familyFilenameSignature() == signature
} ?: listOf(baseFile)
Timber.tag(ReaderFontDiagnosticsTag).i(
"compose.custom.start basePath='${baseFile.absolutePath}' " +
"exists=${baseFile.exists()} bytes=${baseFile.length()} " +
readerFontDiagnosticSummary(baseFile.nameWithoutExtension) +
" siblings=${siblings.joinToString { it.name }}"
)
val seenVariants = mutableSetOf<String>()
val fontList = siblings.flatMap { sibling ->
try {
val variant = sibling.nameWithoutExtension.detectFontVariant()
val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) {
variableReaderFontWeights
} else {
listOf(variant?.weight ?: FontWeight.Normal)
}
Timber.tag(ReaderFontDiagnosticsTag).i(
"compose.custom.candidate file='${sibling.name}' " +
readerFontDiagnosticSummary(sibling.nameWithoutExtension) +
" style=${variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal} " +
"weights=${weights.joinToString { it.weight.toString() }}"
)
weights.mapNotNull { weight ->
val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal
if (seenVariants.add("${weight.weight}|$style")) {
Font(sibling, weight, style)
} else {
Timber.tag(ReaderFontDiagnosticsTag).i(
"compose.custom.skipDuplicate file='${sibling.name}' weight=${weight.weight} style=$style"
)
null
}
}
} catch (e: Exception) {
Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.candidateFailed file='${sibling.name}'")
emptyList()
}
}
if (fontList.isNotEmpty()) {
Timber.tag(ReaderFontDiagnosticsTag).i(
"compose.custom.loaded base='${baseFile.name}' registeredVariants=${seenVariants.joinToString()}"
)
FontFamily(fontList)
} else {
Timber.tag(ReaderFontDiagnosticsTag).w(
"compose.custom.fallbackSingle base='${baseFile.name}' no inferred variants loaded"
)
FontFamily(Font(baseFile))
}
} catch (e: Exception) {
Timber.tag(ReaderFontDiagnosticsTag).e(e, "compose.custom.failed path='$customFontPath'")
FontFamily.Default
}
}
@ -436,6 +503,18 @@ fun getComposeFontFamily(
return FontFamily.Default
}
private val variableReaderFontWeights = listOf(
FontWeight.Thin,
FontWeight.ExtraLight,
FontWeight.Light,
FontWeight.Normal,
FontWeight.Medium,
FontWeight.SemiBold,
FontWeight.Bold,
FontWeight.ExtraBold,
FontWeight.Black
)
fun saveReaderSettings(
context: Context,
fontSize: Float,
@ -853,16 +932,56 @@ fun FontSelectionSheetContent(
)
}
} else {
val customFontFamilies = remember(customFonts) {
val grouped = customFonts
.filterNot { it.isDeleted }
.map { it.toSharedCustomFontItem() }
.groupByFamily()
Timber.tag(ReaderFontDiagnosticsTag).i(
"picker.grouped count=${grouped.size} families=${
grouped.joinToString { family ->
"${family.familyName} -> [" +
family.variants.joinToString { variantItem ->
val font = variantItem.font
"${font.displayName}:${variantItem.variant}"
} + "]"
}
}"
)
grouped
}
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
items(customFonts) { fontEntity ->
val isSelected = currentCustomFontPath == fontEntity.path
val fontFamily = remember(fontEntity.path) {
try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
items(customFontFamilies) { family ->
val baseFont = family.variants.firstOrNull {
val variant = it.variant
variant != null &&
variant.weight == FontWeight.Normal &&
variant.style == androidx.compose.ui.text.font.FontStyle.Normal
}?.font ?: family.variants.first().font
val isSelected = family.variants.any { it.font.path == currentCustomFontPath }
val fontFamily = remember(baseFont.path) {
getComposeFontFamily(ReaderFont.ORIGINAL, baseFont.path)
}
Timber.tag(ReaderFontDiagnosticsTag).i(
"picker.row family='${family.familyName}' base='${baseFont.displayName}' " +
"selected=$isSelected variants=${
family.variants.joinToString { "${it.font.displayName}:${it.variant}" }
}"
)
ListItem(
headlineContent = {
Text(fontEntity.displayName, fontFamily = fontFamily)
Text(family.familyName, fontFamily = fontFamily)
},
supportingContent = {
Text(
buildString {
append(family.fontFaceSummary())
if (family.hasVariableWeightFace()) append(" - Variable weight")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingContent = {
if (isSelected) {
@ -873,7 +992,12 @@ fun FontSelectionSheetContent(
)
}
},
modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) },
modifier = Modifier.clickable {
Timber.tag(ReaderFontDiagnosticsTag).i(
"picker.selected family='${family.familyName}' base='${baseFont.displayName}' path='${baseFont.path}'"
)
onFontSelected(ReaderFont.ORIGINAL, baseFont.path)
},
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
@ -887,6 +1011,18 @@ fun FontSelectionSheetContent(
}
}
private fun CustomFontEntity.toSharedCustomFontItem(): CustomFontItem {
return CustomFontItem(
id = id,
displayName = displayName,
fileName = fileName,
fileExtension = fileExtension,
path = path,
timestamp = timestamp,
isDeleted = isDeleted
)
}
private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding"
fun saveRemoveEdgePadding(context: Context, enabled: Boolean) {
@ -915,6 +1051,8 @@ fun VisualOptionsSheet(
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val configuration = LocalConfiguration.current
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
@ -924,6 +1062,8 @@ fun VisualOptionsSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 8.dp)
) {
Row(

View file

@ -34,6 +34,8 @@ import androidx.compose.ui.input.key.type
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.aryan.reader.paginatedreader.AndroidEpubKeyCommand
import com.aryan.reader.paginatedreader.androidEpubKeyCommandOrNull
import com.aryan.reader.RenderMode
@Composable
@ -150,3 +152,45 @@ fun Modifier.volumeScrollHandler(
}
true
}
fun Modifier.epubReaderKeyboardNavigationHandler(
enabled: Boolean,
renderMode: RenderMode,
isRightToLeftPagination: Boolean,
verticalLineScrollPx: Int,
onVerticalScrollBy: (Int) -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
onFirstPage: () -> Unit,
onLastPage: () -> Unit
): Modifier = this.onPreviewKeyEvent { keyEvent ->
if (!enabled) return@onPreviewKeyEvent false
val command = androidEpubKeyCommandOrNull(
keyCode = keyEvent.nativeKeyEvent.keyCode,
rightToLeftPagination = isRightToLeftPagination,
isCtrlPressed = keyEvent.nativeKeyEvent.isCtrlPressed
) ?: return@onPreviewKeyEvent false
if (keyEvent.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent when (command) {
AndroidEpubKeyCommand.SCROLL_UP,
AndroidEpubKeyCommand.SCROLL_DOWN -> renderMode == RenderMode.VERTICAL_SCROLL
else -> true
}
}
when (command) {
AndroidEpubKeyCommand.SCROLL_UP -> {
if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
onVerticalScrollBy(-verticalLineScrollPx)
}
AndroidEpubKeyCommand.SCROLL_DOWN -> {
if (renderMode != RenderMode.VERTICAL_SCROLL) return@onPreviewKeyEvent false
onVerticalScrollBy(verticalLineScrollPx)
}
AndroidEpubKeyCommand.PREVIOUS_PAGE -> onPreviousPage()
AndroidEpubKeyCommand.NEXT_PAGE -> onNextPage()
AndroidEpubKeyCommand.FIRST_PAGE -> onFirstPage()
AndroidEpubKeyCommand.LAST_PAGE -> onLastPage()
}
true
}

View file

@ -328,16 +328,15 @@ private fun handleVerticalAutoAdvance(
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
if (!nativeChunks.isNullOrEmpty()) {
val resumeIdx = findTtsChunkResumeIndex(
val startChunkIndex = resolveTtsContinuationStartIndex(
chunks = nativeChunks,
loadedChunkCount = loadedChunkCount,
sourceCfi = lastReadCfi,
startOffsetInSource = currentState.startOffsetInSource,
currentText = currentState.currentText,
currentChunkIndexFallback = currentState.currentChunkIndex
currentText = currentState.currentText
)
if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) {
val startChunkIndex = resumeIdx + 1
if (startChunkIndex != null) {
val token = getAuthToken()
ttsController.start(
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),

View file

@ -78,6 +78,29 @@ internal fun findTtsChunkResumeIndex(
return currentChunkIndexFallback.takeIf { it in chunks.indices }
}
internal fun resolveTtsContinuationStartIndex(
chunks: List<TtsChunk>,
loadedChunkCount: Int,
sourceCfi: String?,
startOffsetInSource: Int,
currentText: String?
): Int? {
val matchedResumeIndex = findTtsChunkResumeIndex(
chunks = chunks,
sourceCfi = sourceCfi,
startOffsetInSource = startOffsetInSource,
currentText = currentText,
currentChunkIndexFallback = -1
)
val matchedNextIndex = matchedResumeIndex?.plus(1)
if (matchedNextIndex != null && matchedNextIndex in chunks.indices) {
return matchedNextIndex
}
return loadedChunkCount.takeIf { it in chunks.indices }
}
private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }

View file

@ -0,0 +1,39 @@
package com.aryan.reader.paginatedreader
import android.view.KeyEvent
internal enum class AndroidEpubKeyCommand {
PREVIOUS_PAGE,
NEXT_PAGE,
SCROLL_UP,
SCROLL_DOWN,
FIRST_PAGE,
LAST_PAGE
}
internal fun androidEpubKeyCommandOrNull(
keyCode: Int,
rightToLeftPagination: Boolean = false,
isCtrlPressed: Boolean = false
): AndroidEpubKeyCommand? {
if (isCtrlPressed) return null
return when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> if (rightToLeftPagination) {
AndroidEpubKeyCommand.NEXT_PAGE
} else {
AndroidEpubKeyCommand.PREVIOUS_PAGE
}
KeyEvent.KEYCODE_DPAD_RIGHT -> if (rightToLeftPagination) {
AndroidEpubKeyCommand.PREVIOUS_PAGE
} else {
AndroidEpubKeyCommand.NEXT_PAGE
}
KeyEvent.KEYCODE_DPAD_UP -> AndroidEpubKeyCommand.SCROLL_UP
KeyEvent.KEYCODE_DPAD_DOWN -> AndroidEpubKeyCommand.SCROLL_DOWN
KeyEvent.KEYCODE_PAGE_UP -> AndroidEpubKeyCommand.PREVIOUS_PAGE
KeyEvent.KEYCODE_PAGE_DOWN -> AndroidEpubKeyCommand.NEXT_PAGE
KeyEvent.KEYCODE_MOVE_HOME -> AndroidEpubKeyCommand.FIRST_PAGE
KeyEvent.KEYCODE_MOVE_END -> AndroidEpubKeyCommand.LAST_PAGE
else -> null
}
}

View file

@ -202,6 +202,7 @@ class BookPaginator(
private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>()
private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>()
private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>()
private val expandedAllFontFaces = expandFontFacesWithSiblings(allFontFaces, extractionBasePath)
private var pageCountsAreAccurate by mutableStateOf(false)
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
@ -385,7 +386,7 @@ class BookPaginator(
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
append("-ua:${userAgentStylesheet.hashCode()}")
append("-css:${bookCss.hashCode()}")
append("-fonts:${allFontFaces.hashCode()}")
append("-fonts:${expandedAllFontFaces.hashCode()}")
}
val hash = configString.hashCode()
return hash
@ -872,7 +873,7 @@ class BookPaginator(
density = density.density,
constraintsMaxWidth = constraints.maxWidth,
constraintsMaxHeight = constraints.maxHeight,
fontFaces = this.allFontFaces,
fontFaces = expandedAllFontFaces,
styleConfigHash = currentConfigHash,
bookReplacementPreferencesJson = ReaderBookReplacementPreferencesJson.encode(
bookReplacementPreferences.scopedToFile(bookReplacementFileId),

View file

@ -0,0 +1,132 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.shared.detectFontVariant
import com.aryan.reader.shared.familyFilenameSignature
import com.aryan.reader.shared.fontWeightCssDescriptor
import timber.log.Timber
import java.io.File
private val supportedEpubFontExtensions = setOf("ttf", "otf", "woff", "woff2")
fun expandFontFacesWithSiblings(
fontFaces: List<FontFaceInfo>,
extractionPath: String
): List<FontFaceInfo> {
if (fontFaces.isEmpty()) return emptyList()
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.siblings.start inputCount=${fontFaces.size} extractionPath='$extractionPath'"
)
val result = fontFaces.toMutableList()
val existingKeys = result.mapTo(mutableSetOf()) { it.variantKey() }
val extractionRoot = File(extractionPath)
fontFaces.forEach { fontFace ->
val sourceFile = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@forEach
val sourceSignature = sourceFile.familyFilenameSignature()
if (sourceSignature.isBlank()) return@forEach
val parent = sourceFile.parentFile ?: return@forEach
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.siblings.source family='${fontFace.fontFamily}' src='${fontFace.src}' " +
"file='${sourceFile.name}' " +
readerFontDiagnosticSummary(sourceFile.nameWithoutExtension)
)
parent.listFiles()
?.asSequence()
?.filter { candidate ->
candidate.isFile &&
candidate.extension.lowercase() in supportedEpubFontExtensions &&
candidate.nameWithoutExtension.familyFilenameSignature() == sourceSignature
}
?.forEach { candidate ->
val variant = candidate.nameWithoutExtension.detectFontVariant()
if (variant == null) {
Timber.tag(ReaderFontDiagnosticsTag).w(
"epub.siblings.skipNoVariant file='${candidate.name}' " +
readerFontDiagnosticSummary(candidate.nameWithoutExtension)
)
return@forEach
}
val src = candidate.toFontFaceSrc(extractionRoot)
val inferred = fontFace.copy(
src = src,
fontWeight = variant.weight,
fontStyle = variant.style
)
if (existingKeys.add(inferred.variantKey())) {
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.siblings.add family='${fontFace.fontFamily}' src='$src' variant=$variant"
)
result += inferred
} else {
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.siblings.skipDuplicate family='${fontFace.fontFamily}' src='$src' variant=$variant"
)
}
}
}
Timber.tag(ReaderFontDiagnosticsTag).i("epub.siblings.done outputCount=${result.size}")
return result
}
fun buildEpubFontFaceCss(
fontFaces: List<FontFaceInfo>,
extractionPath: String
): String {
val extractionRoot = File(extractionPath)
return expandFontFacesWithSiblings(fontFaces, extractionPath)
.distinctBy { it.variantKey() }
.mapNotNull { fontFace ->
val file = fontFace.resolvedFile(extractionRoot).takeIf { it.isFile } ?: return@mapNotNull null
val family = fontFace.fontFamily.cssString()
val url = file.toURI().toString().cssUrlString()
val weight = file.nameWithoutExtension.fontWeightCssDescriptor(fontFace.fontWeight ?: FontWeight.Normal)
val style = if (fontFace.fontStyle == FontStyle.Italic) "italic" else "normal"
Timber.tag(ReaderFontDiagnosticsTag).i(
"epub.css.face family='$family' file='${file.name}' fontWeight='$weight' fontStyle='$style' " +
readerFontDiagnosticSummary(file.nameWithoutExtension)
)
"@font-face { font-family: '$family'; src: url('$url'); font-weight: $weight; font-style: $style; }"
}
.joinToString(separator = " ")
}
private fun FontFaceInfo.resolvedFile(extractionRoot: File): File {
val source = File(src)
return if (source.isAbsolute) source else File(extractionRoot, src)
}
private fun FontFaceInfo.variantKey(): String {
return listOf(
fontFamily.trim().lowercase(),
src.replace('\\', '/').lowercase(),
fontWeight?.weight ?: FontWeight.Normal.weight,
fontStyle ?: FontStyle.Normal
).joinToString(separator = "|")
}
private fun File.toFontFaceSrc(extractionRoot: File): String {
val relative = runCatching {
extractionRoot.toPath().relativize(toPath()).toString()
}.getOrNull()
return relative
?.takeIf { !it.startsWith("..") && it.isNotBlank() }
?.replace(File.separatorChar, '/')
?: absolutePath
}
private fun File.familyFilenameSignature(): String {
return nameWithoutExtension.familyFilenameSignature()
}
private fun String.cssString(): String = replace("\\", "\\\\").replace("'", "\\'")
private fun String.cssUrlString(): String = replace("\\", "\\\\").replace("'", "%27")

View file

@ -24,6 +24,9 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import com.aryan.reader.ReaderFontDiagnosticsTag
import com.aryan.reader.readerFontDiagnosticSummary
import com.aryan.reader.shared.supportsVariableWeightAxis
import java.io.File
import java.security.MessageDigest
@ -41,7 +44,11 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
if (fontFaces.isEmpty()) {
return emptyMap()
}
Timber.d("Loading ${fontFaces.size} font faces from extraction path: $extractionPath")
val expandedFontFaces = expandFontFacesWithSiblings(fontFaces, extractionPath)
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.start inputCount=${fontFaces.size} expandedCount=${expandedFontFaces.size} extractionPath='$extractionPath'"
)
Timber.d("Loading ${expandedFontFaces.size} font faces from extraction path: $extractionPath")
// 1. Define a stable, global font cache directory.
// This assumes the parent of the extraction path is a stable base directory for epubs.
@ -55,21 +62,39 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
// e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path.
val bookId = File(extractionPath).name.substringBeforeLast("_")
val fontsByFamily = fontFaces.groupBy {
val fontsByFamily = expandedFontFaces.groupBy {
it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase()
}
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.grouped families=${
fontsByFamily.mapValues { (_, infos) ->
infos.joinToString { "${it.src}:${it.fontWeight}:${it.fontStyle}" }
}
}"
)
Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}")
return fontsByFamily.mapValues { (familyName, fontInfos) ->
val fontList = fontInfos.mapNotNull { fontInfo ->
val seenVariants = mutableSetOf<String>()
val fontList = fontInfos.flatMap { fontInfo ->
try {
Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'")
var fontFile = File(extractionPath, fontInfo.src)
var fontFile = File(fontInfo.src).let { source ->
if (source.isAbsolute) source else File(extractionPath, fontInfo.src)
}
if (!fontFile.exists()) {
Timber.tag(ReaderFontDiagnosticsTag).w(
"native.load.missing family='$familyName' src='${fontInfo.src}' resolved='${fontFile.absolutePath}'"
)
Timber.w("Font file not found at: ${fontFile.absolutePath}")
return@mapNotNull null
return@flatMap emptyList()
}
val sourceName = fontFile.nameWithoutExtension
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.candidate family='$familyName' src='${fontInfo.src}' file='${fontFile.name}' " +
readerFontDiagnosticSummary(sourceName)
)
// Handle WOFF2 conversion and global caching
if (fontFile.extension.equals("woff2", ignoreCase = true)) {
@ -80,9 +105,15 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
if (cachedTtfFile.exists()) {
// Use the globally cached TTF file if it exists
fontFile = cachedTtfFile
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.woff2CacheHit src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}'"
)
Timber.d("Using globally cached TTF for '${fontInfo.src}'")
} else {
// Convert and save the TTF to the global cache if it doesn't exist
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.woff2Convert src='${fontInfo.src}' source='${fontFile.absolutePath}'"
)
Timber.d("Converting woff2 font: ${fontFile.name}")
val woff2Data = fontFile.readBytes()
val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data)
@ -90,31 +121,69 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
if (ttfData != null) {
cachedTtfFile.writeBytes(ttfData)
fontFile = cachedTtfFile // Use the newly created TTF file
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.woff2Converted src='${fontInfo.src}' cached='${cachedTtfFile.absolutePath}' bytes=${cachedTtfFile.length()}"
)
Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'")
} else {
Timber.tag(ReaderFontDiagnosticsTag).e(
"native.load.woff2ConvertFailed src='${fontInfo.src}' source='${fontFile.absolutePath}'"
)
Timber.e("Failed to convert woff2 font: ${fontFile.name}")
return@mapNotNull null
return@flatMap emptyList()
}
}
}
Font(
fontFile,
fontInfo.fontWeight ?: FontWeight.Normal,
fontInfo.fontStyle ?: FontStyle.Normal
val weights = if (sourceName.supportsVariableWeightAxis()) {
variableEpubFontWeights
} else {
listOf(fontInfo.fontWeight ?: FontWeight.Normal)
}
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.registerPlan family='$familyName' file='${fontFile.name}' " +
"style=${fontInfo.fontStyle ?: FontStyle.Normal} weights=${weights.joinToString { it.weight.toString() }}"
)
weights.mapNotNull { weight ->
val style = fontInfo.fontStyle ?: FontStyle.Normal
if (seenVariants.add("${weight.weight}|$style")) {
Font(fontFile, weight, style)
} else {
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.skipDuplicate family='$familyName' file='${fontFile.name}' weight=${weight.weight} style=$style"
)
null
}
}
} catch (e: Exception) {
Timber.tag(ReaderFontDiagnosticsTag).e(e, "native.load.failed family='$familyName' src='${fontInfo.src}'")
Timber.e(e, "Error loading font: ${fontInfo.src}")
null
emptyList()
}
}
if (fontList.isNotEmpty()) {
Timber.tag(ReaderFontDiagnosticsTag).i(
"native.load.loaded family='$familyName' registeredVariants=${seenVariants.joinToString()}"
)
Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.")
FontFamily(fontList)
} else {
Timber.tag(ReaderFontDiagnosticsTag).w("native.load.empty family='$familyName'")
Timber.w("Could not load any font styles for family '$familyName'.")
null
}
}.filterValues { it != null }.mapValues { it.value!! }
}
private val variableEpubFontWeights = listOf(
FontWeight.Thin,
FontWeight.ExtraLight,
FontWeight.Light,
FontWeight.Normal,
FontWeight.Medium,
FontWeight.SemiBold,
FontWeight.Bold,
FontWeight.ExtraBold,
FontWeight.Black
)

View file

@ -5,14 +5,13 @@ package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import android.widget.Toast
import com.aryan.reader.BuildConfig
import com.aryan.reader.copyPlainTextToClipboard
import androidx.compose.ui.unit.isSpecified
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
@ -175,6 +174,7 @@ import com.aryan.reader.epubreader.UserHighlight
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.shared.ReaderBookReplacementPreferences
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
@ -192,6 +192,8 @@ import timber.log.Timber
import java.io.File
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Base64
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.sqrt
@ -243,7 +245,8 @@ data class NativeVerticalLocation(
val firstVisibleItemSize: Int,
val isAtStart: Boolean,
val isAtEnd: Boolean,
val visibleTextRanges: List<NativeVerticalVisibleTextRange> = emptyList()
val visibleTextRanges: List<NativeVerticalVisibleTextRange> = emptyList(),
val chapterPageInfo: NativeVerticalChapterPageInfo? = null
)
data class NativeVerticalVisibleTextRange(
@ -253,6 +256,34 @@ data class NativeVerticalVisibleTextRange(
val endCharOffset: Int
)
fun NativeVerticalLocation.locatorForPersistence(): Locator? {
val visibleRange = visibleTextRanges.firstOrNull()
return if (visibleRange != null) {
Locator(
chapterIndex = visibleRange.chapterIndex,
blockIndex = visibleRange.blockIndex,
charOffset = visibleRange.startCharOffset
)
} else {
locator
}
}
internal fun shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator: Boolean,
didLocatorScroll: Boolean
): Boolean = !hasInitialLocator && !didLocatorScroll
internal fun nativeVerticalCenteredScrollDelta(
targetOffsetInViewport: Float,
viewportHeight: Float
): Float = targetOffsetInViewport - (viewportHeight * 0.5f)
data class NativeVerticalChapterPageInfo(
val currentPage: Int,
val totalPages: Int
)
private data class SelectionBlockKey(
val pageIndex: Int,
val blockIndex: Int,
@ -318,7 +349,7 @@ internal fun nativeVerticalInitialChapterPrefetchOrder(
chapterCount: Int,
initialChapter: Int,
forwardCount: Int = 2,
backwardCount: Int = 1
backwardCount: Int = 0
): List<Int> {
if (chapterCount <= 0) return emptyList()
val start = initialChapter.coerceIn(0, chapterCount - 1)
@ -1026,6 +1057,68 @@ internal fun nativeVerticalProgressForCompatPage(pageIndex: Int, totalPageCount:
.coerceIn(0f, 100f)
}
internal fun nativeVerticalChapterPageInfo(
chapterCharOffset: Int?,
chapterLengthChars: Int,
chapterPageCount: Int?,
compatPageIndex: Int,
chapterStartPageIndex: Int?
): NativeVerticalChapterPageInfo? {
val total = chapterPageCount?.takeIf { it > 0 } ?: return null
val pageIndexInChapter = if (chapterCharOffset != null && chapterLengthChars > 0) {
((chapterCharOffset.coerceIn(0, chapterLengthChars).toFloat() / chapterLengthChars.toFloat()) * (total - 1))
.roundToInt()
} else if (chapterStartPageIndex != null) {
compatPageIndex - chapterStartPageIndex
} else {
0
}.coerceIn(0, total - 1)
return NativeVerticalChapterPageInfo(
currentPage = pageIndexInChapter + 1,
totalPages = total
)
}
internal fun nativeVerticalChapterPageInfoForScroll(
itemChapterIndices: List<Int>,
itemWeights: List<Int>,
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
firstVisibleItemSize: Int,
chapterPageCount: Int?
): NativeVerticalChapterPageInfo? {
val total = chapterPageCount?.takeIf { it > 0 } ?: return null
if (itemChapterIndices.isEmpty() || itemWeights.isEmpty()) {
return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
}
val safeIndex = firstVisibleItemIndex.coerceIn(0, minOf(itemChapterIndices.lastIndex, itemWeights.lastIndex))
val chapterIndex = itemChapterIndices[safeIndex]
val chapterItems = itemChapterIndices.indices.filter { index ->
index < itemWeights.size && itemChapterIndices[index] == chapterIndex
}
val totalChapterWeight = chapterItems.sumOf { itemWeights[it].coerceAtLeast(0) }
if (totalChapterWeight <= 0) {
return NativeVerticalChapterPageInfo(currentPage = 1, totalPages = total)
}
val completedWeight = chapterItems
.filter { it < safeIndex }
.sumOf { itemWeights[it].coerceAtLeast(0) }
val currentWeight = itemWeights[safeIndex].coerceAtLeast(0)
val currentFraction = if (firstVisibleItemSize > 0) {
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
.coerceIn(0f, 1f)
} else {
0f
}
val chapterProgress = ((completedWeight + currentWeight * currentFraction) / totalChapterWeight.toFloat())
.coerceIn(0f, 1f)
val pageIndexInChapter = (chapterProgress * (total - 1)).roundToInt().coerceIn(0, total - 1)
return NativeVerticalChapterPageInfo(
currentPage = pageIndexInChapter + 1,
totalPages = total
)
}
internal fun nativeVerticalProgressToItemIndex(
itemWeights: List<Int>,
progressPercent: Float
@ -1119,31 +1212,45 @@ private fun findNativeVerticalFlowItemIndexForProgress(
)
}
private fun estimateNativeVerticalScrollProgressPercent(
items: List<NativeVerticalFlowItem>,
internal fun estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights: List<Int>,
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
firstVisibleItemSize: Int
): Float? {
if (items.isEmpty()) return null
val totalWeight = items.sumOf { it.locationWeight }.takeIf { it > 0 } ?: return null
val safeIndex = firstVisibleItemIndex.coerceIn(0, items.lastIndex)
val completedWeight = items
if (itemWeights.isEmpty()) return null
val totalWeight = itemWeights.sumOf { it }.takeIf { it > 0 } ?: return null
val safeIndex = firstVisibleItemIndex.coerceIn(0, itemWeights.lastIndex)
val completedWeight = itemWeights
.take(safeIndex)
.sumOf { it.locationWeight }
val currentItem = items[safeIndex]
.sum()
val currentItemWeight = itemWeights[safeIndex]
val currentFraction = if (firstVisibleItemSize > 0) {
(firstVisibleItemScrollOffset.toFloat() / firstVisibleItemSize.toFloat())
.coerceIn(0f, 1f)
} else {
0f
}
val weightedPosition = completedWeight + (currentItem.locationWeight * currentFraction)
val weightedPosition = completedWeight + (currentItemWeight * currentFraction)
return ((weightedPosition.toDouble() / totalWeight.toDouble()) * 100.0)
.toFloat()
.coerceIn(0f, 100f)
}
private fun estimateNativeVerticalScrollProgressPercent(
items: List<NativeVerticalFlowItem>,
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
firstVisibleItemSize: Int
): Float? {
return estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights = items.map { it.locationWeight },
firstVisibleItemIndex = firstVisibleItemIndex,
firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
firstVisibleItemSize = firstVisibleItemSize
)
}
private fun findNativeVerticalFlowItemIndexForLocator(
items: List<NativeVerticalFlowItem>,
chapters: List<NativeVerticalFlowChapter>,
@ -1333,7 +1440,7 @@ private fun resolveNativeVerticalVisibleTextRanges(
val start = blockStart + (firstVisibleOffset ?: 0)
val end = blockStart + (lastVisibleOffset ?: block.content.text.length)
NativeVerticalVisibleTextRange(
bounds.top to NativeVerticalVisibleTextRange(
chapterIndex = chapterIndex,
blockIndex = block.blockIndex,
startCharOffset = start,
@ -1341,6 +1448,8 @@ private fun resolveNativeVerticalVisibleTextRanges(
)
}
}
.sortedBy { it.first }
.map { it.second }
.toList()
}
@ -1903,6 +2012,37 @@ private fun imageContentScale(style: BlockStyle): ContentScale {
}
}
internal fun nativeVerticalSvgContentFromDataUri(source: String): String? {
if (!source.startsWith("data:image/svg+xml", ignoreCase = true)) return null
val commaIndex = source.indexOf(',')
if (commaIndex < 0) return null
val metadata = source.substring(0, commaIndex)
val payload = source.substring(commaIndex + 1)
return runCatching {
if (metadata.contains(";base64", ignoreCase = true)) {
String(Base64.getDecoder().decode(payload), StandardCharsets.UTF_8)
} else {
URLDecoder.decode(payload.replace("+", "%2B"), "UTF-8")
}
}.getOrNull()
}
internal fun nativeVerticalImageModelData(source: String): Any {
val trimmed = source.trim()
return when {
trimmed.startsWith("<svg", ignoreCase = true) -> SvgData(trimmed)
trimmed.startsWith("data:image/svg+xml", ignoreCase = true) ->
nativeVerticalSvgContentFromDataUri(trimmed)?.let { SvgData(it) } ?: trimmed
trimmed.startsWith("file:", ignoreCase = true) ||
trimmed.startsWith("content:", ignoreCase = true) ||
trimmed.startsWith("android.resource:", ignoreCase = true) ||
trimmed.startsWith("http://", ignoreCase = true) ||
trimmed.startsWith("https://", ignoreCase = true) -> trimmed.toUri()
trimmed.startsWith("data:", ignoreCase = true) -> trimmed
else -> File(trimmed)
}
}
private fun tableCellImageModifier(
block: ImageBlock,
density: Density,
@ -2023,7 +2163,9 @@ private fun WrappingContentLayout(
Layout(content = {
AsyncImage(
model = Builder(LocalContext.current).data(File(block.floatedImage.path)).build(),
model = Builder(LocalContext.current)
.data(nativeVerticalImageModelData(block.floatedImage.path))
.build(),
contentDescription = block.floatedImage.altText,
contentScale = imageContentScale(block.floatedImage.style)
)
@ -2971,7 +3113,7 @@ fun PaginatedReaderScreen(
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalSerializationApi::class, FlowPreview::class)
@OptIn(ExperimentalSerializationApi::class)
@Composable
fun NativeVerticalReaderScreen(
modifier: Modifier = Modifier,
@ -3342,19 +3484,19 @@ fun NativeVerticalReaderScreen(
)
if (exactDelta != null) {
val scrollDelta = if (keepVisible) {
val viewportHeight = rootWindowBounds.height
val comfortableTop = viewportHeight * 0.24f
val comfortableBottom = viewportHeight * 0.76f
if (exactDelta in comfortableTop..comfortableBottom) {
0f
} else {
exactDelta - (viewportHeight * 0.38f)
}
nativeVerticalCenteredScrollDelta(
targetOffsetInViewport = exactDelta,
viewportHeight = rootWindowBounds.height
)
} else {
exactDelta
}
if (abs(scrollDelta) > 1f) {
listState.scrollBy(scrollDelta)
if (animate) {
listState.animateScrollBy(scrollDelta)
} else {
listState.scrollBy(scrollDelta)
}
}
if (keepVisible || abs(exactDelta) > 1f) return true
}
@ -3364,7 +3506,11 @@ fun NativeVerticalReaderScreen(
chapters = chapters,
locator = locator
) ?: return false
listState.scrollToItem(targetIndex)
if (animate) {
listState.animateScrollToItem(targetIndex)
} else {
listState.scrollToItem(targetIndex)
}
repeat(4) {
withFrameNanos { }
val refinedDelta = resolveNativeVerticalScrollDeltaForLocator(
@ -3379,13 +3525,19 @@ fun NativeVerticalReaderScreen(
)
if (refinedDelta != null) {
val scrollDelta = if (keepVisible) {
val viewportHeight = rootWindowBounds.height
refinedDelta - (viewportHeight * 0.38f)
nativeVerticalCenteredScrollDelta(
targetOffsetInViewport = refinedDelta,
viewportHeight = rootWindowBounds.height
)
} else {
refinedDelta
}
if (abs(scrollDelta) > 1f) {
listState.scrollBy(scrollDelta)
if (animate) {
listState.animateScrollBy(scrollDelta)
} else {
listState.scrollBy(scrollDelta)
}
}
return true
}
@ -3441,8 +3593,11 @@ fun NativeVerticalReaderScreen(
prefetchOrder.forEach { chapterIndex ->
if (!isActive) return@LaunchedEffect
while (isActive && listState.isScrollInProgress) {
delay(80L)
}
loadFlowChapter(chapterIndex)
delay(16L)
delay(80L)
}
}
@ -3453,9 +3608,18 @@ fun NativeVerticalReaderScreen(
didInitialScroll = true
return@LaunchedEffect
}
val didScroll = scrollToFlowLocator(targetLocator, animate = false) ||
scrollToCompatPage(initialNativePageIndex, animate = false)
if (didScroll) {
val didLocatorScroll = scrollToFlowLocator(targetLocator, animate = false)
val didScroll = didLocatorScroll ||
if (shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator = initialNativeLocator != null,
didLocatorScroll = didLocatorScroll
)
) {
scrollToCompatPage(initialNativePageIndex, animate = false)
} else {
false
}
if (didScroll || initialNativeLocator != null) {
didInitialScroll = true
}
}
@ -3471,7 +3635,12 @@ fun NativeVerticalReaderScreen(
LaunchedEffect(scrollRequestLocatorId, scrollRequestLocator, scrollRequestLocatorKeepVisible, flowChapters, rootWindowBounds) {
val requestedLocator = scrollRequestLocator ?: return@LaunchedEffect
if (flowChapters == null || rootWindowBounds == Rect.Zero) return@LaunchedEffect
if (scrollToFlowLocator(requestedLocator, animate = false, keepVisible = scrollRequestLocatorKeepVisible)) {
if (scrollToFlowLocator(
locator = requestedLocator,
animate = scrollRequestLocatorKeepVisible,
keepVisible = scrollRequestLocatorKeepVisible
)
) {
paginator.onUserScrolledTo(
nativeVerticalCompatPageForProgress(
estimateNativeVerticalProgressPercent(book, requestedLocator) ?: 0f,
@ -3506,6 +3675,7 @@ fun NativeVerticalReaderScreen(
var lastReportedTotalPageCount by remember { mutableIntStateOf(0) }
var lastReportedProgressPercent by remember { mutableFloatStateOf(-1f) }
var lastReportedLocator by remember { mutableStateOf<Locator?>(null) }
var lastReportedChapterPageInfo by remember { mutableStateOf<NativeVerticalChapterPageInfo?>(null) }
var lastReportedVisibleTextRanges by remember { mutableStateOf<List<NativeVerticalVisibleTextRange>>(emptyList()) }
LaunchedEffect(paginator, totalPageCount, rootWindowBounds, blockLayoutMap, flowChapters, flowItems) {
@ -3531,7 +3701,6 @@ fun NativeVerticalReaderScreen(
initialScrollComplete = didInitialScroll
)
}
.debounce(80)
.collectLatest { sample ->
if (!sample.initialScrollComplete) return@collectLatest
val total = sample.totalPageCount
@ -3561,18 +3730,32 @@ fun NativeVerticalReaderScreen(
}
val compatPage = nativeVerticalCompatPageForProgress(progressPercent, total)
paginator.onUserScrolledTo(compatPage)
val visibleChapterIndex = locator?.chapterIndex
?: flowItems.getOrNull(sample.firstVisiblePageIndex)?.chapterIndex
val chapterPageInfo = visibleChapterIndex?.let { chapterIndex ->
nativeVerticalChapterPageInfoForScroll(
itemChapterIndices = flowItems.map { it.chapterIndex },
itemWeights = flowItems.map { it.locationWeight },
firstVisibleItemIndex = sample.firstVisiblePageIndex,
firstVisibleItemScrollOffset = sample.firstVisiblePageScrollOffset,
firstVisibleItemSize = sample.firstVisibleItemSize,
chapterPageCount = paginator.chapterPageCounts[chapterIndex]
)
}
if (
compatPage != lastReportedVisiblePage ||
total != lastReportedTotalPageCount ||
abs(progressPercent - lastReportedProgressPercent) >= 0.05f ||
locator != lastReportedLocator ||
chapterPageInfo != lastReportedChapterPageInfo ||
visibleTextRanges != lastReportedVisibleTextRanges
) {
lastReportedVisiblePage = compatPage
lastReportedTotalPageCount = total
lastReportedProgressPercent = progressPercent
lastReportedLocator = locator
lastReportedChapterPageInfo = chapterPageInfo
lastReportedVisibleTextRanges = visibleTextRanges
onLocationChanged(
NativeVerticalLocation(
@ -3586,7 +3769,8 @@ fun NativeVerticalReaderScreen(
firstVisibleItemSize = sample.firstVisibleItemSize,
isAtStart = sample.isAtStart,
isAtEnd = sample.isAtEnd,
visibleTextRanges = visibleTextRanges
visibleTextRanges = visibleTextRanges,
chapterPageInfo = chapterPageInfo
)
)
onProgressChanged(compatPage, total, progressPercent)
@ -3644,11 +3828,14 @@ fun NativeVerticalReaderScreen(
},
dismissButton = {
TextButton(onClick = {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), urlToShow)
val copied = copyPlainTextToClipboard(
context = context,
label = context.getString(R.string.clip_label_copied_text),
text = urlToShow
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_copy)) }
}
@ -3673,7 +3860,8 @@ fun NativeVerticalReaderScreen(
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize(),
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(listState),
contentPadding = PaddingValues(top = verticalPadding, bottom = verticalPadding)
) {
itemsIndexed(
@ -3820,11 +4008,14 @@ fun NativeVerticalReaderScreen(
) {
PaginatedTextSelectionMenu(
onCopy = {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text)
val copied = copyPlainTextToClipboard(
context = context,
label = context.getString(R.string.clip_label_copied_text),
text = sel.text
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
activeSelection = null
},
onSelectAll = null,
@ -5806,10 +5997,14 @@ internal fun PaginatedReaderContent(
Row(horizontalArrangement = Arrangement.End) {
TextButton(
onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_link), urlToShow)
clipboard.setPrimaryClip(clip)
val copied = copyPlainTextToClipboard(
context = context,
label = context.getString(R.string.clip_label_copied_link),
text = urlToShow
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
showExternalLinkDialog = null
}) { Text(stringResource(R.string.action_copy)) }
TextButton(
@ -7535,10 +7730,14 @@ internal fun PaginatedReaderContent(
) {
PaginatedTextSelectionMenu(
onCopy = {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(context.getString(R.string.clip_label_copied_text), sel.text)
clipboardManager.setPrimaryClip(clip)
val copied = copyPlainTextToClipboard(
context = context,
label = context.getString(R.string.clip_label_copied_text),
text = sel.text
)
if (!copied) {
Toast.makeText(context, context.getString(R.string.error_copy_to_clipboard), Toast.LENGTH_SHORT).show()
}
activeSelection = null
},
onSelectAll = null,
@ -8107,15 +8306,15 @@ private fun RenderFlexChildBlock(
searchHighlighted
}
// Apply block specific styles (like header font weight)
val finalStyle = if (block is HeaderBlock) {
createHeaderTextStyle(
val finalStyle = when (block) {
is HeaderBlock -> createHeaderTextStyle(
baseStyle = textStyle,
level = block.level,
textAlign = block.textAlign
)
} else {
textStyle
is ParagraphBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
is QuoteBlock -> textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
is ListItemBlock -> textStyle
}
TextWithEmphasis(
@ -8157,7 +8356,7 @@ private fun RenderFlexChildBlock(
if (itemMarkerImage != null) {
val imageRequest =
Builder(LocalContext.current).data(File(itemMarkerImage))
Builder(LocalContext.current).data(nativeVerticalImageModelData(itemMarkerImage))
.crossfade(true).build()
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
@ -8227,7 +8426,7 @@ private fun RenderFlexChildBlock(
} else if (style.width != Dp.Unspecified && style.width > 0.dp) {
Modifier.width(style.width)
} else {
Modifier
Modifier.fillMaxWidth()
}
)
.then(
@ -8250,7 +8449,7 @@ private fun RenderFlexChildBlock(
)
AsyncImage(
model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true)
model = Builder(LocalContext.current).data(nativeVerticalImageModelData(childBlock.path)).crossfade(true)
.build(),
contentDescription = childBlock.altText,
modifier = imageModifier,
@ -8338,9 +8537,7 @@ private fun RenderFlexChildBlock(
} else if (blockInCell is ImageBlock) {
AsyncImage(
model = Builder(LocalContext.current).data(
File(
blockInCell.path
)
nativeVerticalImageModelData(blockInCell.path)
).build(),
contentDescription = blockInCell.altText,
contentScale = imageContentScale(blockInCell.style),

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
import kotlin.math.roundToInt
@ -30,6 +31,8 @@ import kotlin.random.Random
private const val PDF_PREVIEW_MAX_WIDTH_PX = 1080
private const val PDF_PREVIEW_MAX_HEIGHT_PX = 2048
private const val PDF_PREVIEW_MAX_BYTES = 16L * 1024L * 1024L
private const val PDF_ENCRYPT_MARKER_TAIL_BYTES = 512 * 1024
private val PDF_ENCRYPT_MARKER = "/Encrypt".toByteArray(Charsets.US_ASCII)
object PdfiumCoreProvider {
val core: PdfiumCoreKt by lazy {
@ -42,7 +45,8 @@ internal data class DocumentCacheItem(
val pfd: ParcelFileDescriptor?,
val totalPages: Int,
val pageAspectRatios: List<Float>,
val flatTableOfContents: List<TocEntry>
val flatTableOfContents: List<TocEntry>,
val isPasswordProtectedPdf: Boolean = false
)
internal class DocumentCache(val maxSize: Int = 3) {
@ -123,6 +127,68 @@ class PdfPrintDocumentAdapter(
}
}
internal fun pdfBytesContainEncryptMarker(bytes: ByteArray): Boolean {
for (index in 0..bytes.size - PDF_ENCRYPT_MARKER.size) {
var matches = true
for (offset in PDF_ENCRYPT_MARKER.indices) {
if (bytes[index + offset] != PDF_ENCRYPT_MARKER[offset]) {
matches = false
break
}
}
if (matches && bytes.getOrNull(index + PDF_ENCRYPT_MARKER.size)?.isPdfNameDelimiter() != false) {
return true
}
}
return false
}
internal fun isPdfLikelyEncryptedForPrint(context: Context, uri: Uri): Boolean {
return try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
FileInputStream(pfd.fileDescriptor).use { input ->
val knownSize = pfd.statSize.takeIf { it >= 0L }
?: runCatching { input.channel.size() }.getOrNull()?.takeIf { it >= 0L }
val tailBytes = if (knownSize != null && knownSize > PDF_ENCRYPT_MARKER_TAIL_BYTES) {
input.channel.position(knownSize - PDF_ENCRYPT_MARKER_TAIL_BYTES)
input.readBytes()
} else if (knownSize != null) {
input.readBytes()
} else {
input.readLastBytes(PDF_ENCRYPT_MARKER_TAIL_BYTES)
}
pdfBytesContainEncryptMarker(tailBytes)
}
} ?: false
} catch (e: Exception) {
Timber.tag("PdfPrint").w(e, "Could not inspect PDF encryption marker before print")
false
}
}
private fun Byte.isPdfNameDelimiter(): Boolean {
return when (toInt().toChar()) {
'\u0000', '\t', '\n', '\u000C', '\r', ' ', '(', ')', '<', '>', '[', ']', '{', '}', '/', '%' -> true
else -> false
}
}
private fun FileInputStream.readLastBytes(maxBytes: Int): ByteArray {
val output = ByteArrayOutputStream(maxBytes)
val buffer = ByteArray(8192)
var bytesRead: Int
while (read(buffer).also { bytesRead = it } > 0) {
if (output.size() + bytesRead <= maxBytes) {
output.write(buffer, 0, bytesRead)
} else {
val combined = output.toByteArray() + buffer.copyOf(bytesRead)
output.reset()
output.write(combined, combined.size - maxBytes, maxBytes)
}
}
return output.toByteArray()
}
internal fun generateShortId(): String {
return Random.nextInt(1000, 9999).toString()
}

View file

@ -166,7 +166,7 @@ private fun Throwable.readablePdfErrorDetail(): String {
private const val PDF_TILE_SIZE_DP = 256
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
private const val PDF_TILE_SCALE_TOLERANCE = 0.03f
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 60L
private const val PDF_TILE_RENDER_IDLE_COOLDOWN_MS = 220L
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
@ -462,7 +462,13 @@ internal fun PdfPageComposable(
var actualBitmapHeightPx by remember(targetPageId) { mutableIntStateOf(0) }
var currentPageRotation by remember(targetPageId) { mutableIntStateOf(0) }
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
val needsTilingNow = shouldRenderPdfHighResTiles(
effectiveScale = effectiveScale,
targetWidthPx = actualBitmapWidthPx,
targetHeightPx = actualBitmapHeightPx,
isVerticalScroll = isVerticalScroll,
isActivePage = isActivePage
)
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
@ -1241,7 +1247,7 @@ internal fun PdfPageComposable(
}
}
if (latestShouldPauseHighResTileRendering && renderScale > 1f) {
if (latestShouldPauseHighResTileRendering) {
if (shouldLogTileSample) {
PdfVerticalPerfLog.d(
"tile-render-paused mode=$tileLogMode page=$pageIndex reason=motion scale=${PdfVerticalPerfLog.f(renderScale)} " +
@ -1260,7 +1266,7 @@ internal fun PdfPageComposable(
}
delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
if (!isActive) return@collectLatest
if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
if (latestShouldPauseHighResTileRendering) {
if (shouldLogHighResTile) {
PdfVerticalPerfLog.d(
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-resumed missing=${tilesToRenderIds.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
@ -1323,7 +1329,7 @@ internal fun PdfPageComposable(
)
}
if (!isActive) return@withLock
if (latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
if (latestShouldPauseHighResTileRendering) {
if (shouldLogHighResTile) {
PdfVerticalPerfLog.d(
"tile-render-canceled mode=$tileLogMode page=$pageIndex reason=motion-started-before-native tile=$tileId scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
@ -1380,7 +1386,7 @@ internal fun PdfPageComposable(
}
return@collectLatest
}
if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering && latestEffectiveScale > 1f) {
if (renderedTiles.isNotEmpty() && latestShouldPauseHighResTileRendering) {
if (shouldLogHighResTile) {
PdfVerticalPerfLog.d(
"tile-render-discarded mode=$tileLogMode page=$pageIndex reason=motion-before-commit rendered=${renderedTiles.size} scale=${PdfVerticalPerfLog.f(latestEffectiveScale)}"
@ -4040,9 +4046,9 @@ internal fun PdfPageComposable(
val stableTiles = remember(tiles) { StableHolder(tiles) }
val stableColorFilter = remember(colorFilter) { StableHolder(colorFilter) }
val stableImageRects = remember(imageScreenRects) { StableHolder(imageScreenRects) }
val shouldDrawHighResTiles = !shouldPauseHighResTileRendering
val shouldDrawHighResTiles = !shouldPauseHighResTileRendering && needsTilingNow
LaunchedEffect(shouldDrawHighResTiles, stableTiles.item.size, effectiveScale) {
if (stableTiles.item.isNotEmpty() && effectiveScale > 1f) {
if (stableTiles.item.isNotEmpty() && shouldDrawHighResTiles) {
PdfVerticalPerfLog.d(
"tile-display mode=${if (isVerticalScroll) "vertical" else "pagination"} page=$pageIndex " +
"visible=$shouldDrawHighResTiles tiles=${stableTiles.item.size} pause=$shouldPauseHighResTileRendering " +
@ -4513,8 +4519,7 @@ private fun PdfBitmapLayer(
}
}
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
if (needsTiling && shouldDrawHighResTiles) {
if (shouldDrawHighResTiles) {
tiles.forEach { tile ->
if (
tile.bitmap.isCanvasSafeBitmap(
@ -5353,7 +5358,7 @@ private fun PdfPageRenderer(
) {
MagnifierComposable(
sourceBitmap = staticData.bitmap.item.asImageBitmap(),
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
tiles = if (staticData.shouldDrawHighResTiles) staticData.tiles.item else emptyList(),
currentScale = effectiveScale,
magnifierCenterOnBitmap = magnifierCenterTarget,
contentWidthPx = staticData.targetWidth,

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.background
import androidx.compose.foundation.gestures.detectDragGestures
@ -28,8 +29,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
@ -64,6 +67,7 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -74,6 +78,7 @@ import com.aryan.reader.R
import com.aryan.reader.epubreader.OptionSegmentedControl
import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.epubreader.titleRes
import com.aryan.reader.readerModalMaxHeightDp
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
@ -568,6 +573,8 @@ fun PdfVisualOptionsSheet(
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val configuration = LocalConfiguration.current
val maxSheetHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
@ -577,6 +584,8 @@ fun PdfVisualOptionsSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 8.dp)
.padding(bottom = 32.dp)
) {

View file

@ -72,7 +72,8 @@ internal fun pdfOverflowMenuSections(
hasHiddenToolbarTools: Boolean,
isPro: Boolean,
effectiveFileType: FileType,
hasFileInfo: Boolean = true
hasFileInfo: Boolean = true,
canPrintDocument: Boolean = true
): List<PdfOverflowMenuSection> = buildList {
add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR)
if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS)
@ -96,7 +97,7 @@ internal fun pdfOverflowMenuSections(
if (
!hiddenTools.contains(PdfReaderTool.SHARE.name) ||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) ||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name))
(effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name))
) {
add(PdfOverflowMenuSection.FILE_ACTIONS)
}
@ -136,6 +137,7 @@ internal fun PdfTopBar(
isReflowingThisBook: Boolean,
hasReflowFile: Boolean,
isPdfDocumentLoaded: Boolean,
canPrintDocument: Boolean = true,
isTabsEnabled: Boolean,
openTabs: List<RecentFileItem>,
activeTabBookId: String?,
@ -395,12 +397,13 @@ internal fun PdfTopBar(
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
val showPrintAction = effectiveFileType == FileType.PDF && canPrintDocument && !hiddenTools.contains(PdfReaderTool.PRINT.name)
pdfOverflowMenuSections(
hiddenTools = hiddenTools,
hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
isPro = BuildConfig.IS_PRO,
effectiveFileType = effectiveFileType
effectiveFileType = effectiveFileType,
canPrintDocument = canPrintDocument
).forEachIndexed { index, section ->
if (index > 0) HorizontalDivider()
when (section) {

View file

@ -378,7 +378,6 @@ fun PdfViewerScreen(
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
var rightToLeftPagination by remember { mutableStateOf(loadPdfRightToLeftPagination(context)) }
var showScreenOrientationSheet by remember { mutableStateOf(false) }
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
var isScrollLocked by remember { mutableStateOf(false) }
var lockedState by remember { mutableStateOf<Triple<Float, Float, Float>?>(null) }
@ -454,6 +453,8 @@ fun PdfViewerScreen(
val uiState by viewModel.uiState.collectAsState()
val effectivePdfUri = uiState.selectedPdfUri ?: pdfUri
val effectiveFileType = uiState.selectedFileType ?: FileType.PDF
var documentPassword by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf<String?>(null) }
var isPrintBlockedForPasswordProtectedPdf by rememberSaveable(effectivePdfUri.toString()) { mutableStateOf(false) }
val isComicFile = effectiveFileType in COMIC_ARCHIVE_FILE_TYPES
var showNewTabSheet by remember { mutableStateOf(false) }
@ -598,7 +599,11 @@ fun PdfViewerScreen(
isAutoScrollLocal = loadPdfAutoScrollLocalMode(context, bookId)
}
val onPrintDocument: () -> Unit = {
val onPrintDocument: () -> Unit = onPrintDocument@{
if (isPrintBlockedForPasswordProtectedPdf) {
showBanner(context.getString(R.string.error_print_password_protected), isError = true)
return@onPrintDocument
}
val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager
val jobName = "${context.getString(R.string.app_name)} - $originalFileName"
@ -2423,8 +2428,9 @@ fun PdfViewerScreen(
}
}
LaunchedEffect(currentPageScale) {
if (currentPageScale != 1f) {
val zoomIndicatorPercentage = pdfZoomIndicatorPercent(currentPageScale)
LaunchedEffect(zoomIndicatorPercentage) {
if (shouldShowPdfZoomIndicator(zoomIndicatorPercentage)) {
showZoomIndicator = true
delay(1500)
showZoomIndicator = false
@ -3518,6 +3524,7 @@ fun PdfViewerScreen(
isDocumentReady = false
errorMessage = null
documentMetadataTitle = null
isPrintBlockedForPasswordProtectedPdf = false
currentBookId = null
areAnnotationsLoaded = false
loadedSidecarBookId = null
@ -3591,6 +3598,7 @@ fun PdfViewerScreen(
totalPages = cachedItem.totalPages
pageAspectRatios = cachedItem.pageAspectRatios
flatTableOfContents = cachedItem.flatTableOfContents
isPrintBlockedForPasswordProtectedPdf = cachedItem.isPasswordProtectedPdf
val mapPage = tabStateMap[currentBookId!!]
val uiPage = uiState.initialPageInBook
@ -3634,6 +3642,8 @@ fun PdfViewerScreen(
val selectedDocumentType = uiState.selectedFileType ?: FileType.PDF
val doc = DocumentFactory.loadDocument(context, effectivePdfUri, selectedDocumentType, documentPassword, pdfiumCore)
val loadedPasswordProtectedPdf = selectedDocumentType == FileType.PDF &&
(documentPassword != null || isPdfLikelyEncryptedForPrint(context, effectivePdfUri))
if (!isActive) {
doc.close()
@ -3641,6 +3651,7 @@ fun PdfViewerScreen(
}
pdfDocument = doc
isPrintBlockedForPasswordProtectedPdf = loadedPasswordProtectedPdf
documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper ->
PdfiumEngineProvider.withPdfium {
wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() }
@ -3737,7 +3748,8 @@ fun PdfViewerScreen(
pfd = null,
totalPages = pagesCount,
pageAspectRatios = ratios,
flatTableOfContents = flatTableOfContents
flatTableOfContents = flatTableOfContents,
isPasswordProtectedPdf = loadedPasswordProtectedPdf
)
)
@ -4558,6 +4570,8 @@ fun PdfViewerScreen(
val latestSpreadScale = rememberUpdatedState(currentActiveScale)
val latestSpreadOffset = rememberUpdatedState(currentActiveOffset)
val spreadPageGap = if (showVerticalPageGap) 8.dp else 0.dp
val spreadPageGapPx = with(density) { spreadPageGap.toPx() }
val spreadPageCount = spreadPageIndices.size
var spreadPanFlingJob by remember { mutableStateOf<Job?>(null) }
Row(
modifier = Modifier
@ -4930,6 +4944,20 @@ fun PdfViewerScreen(
) {
spreadPageIndices.forEach { pageIndex ->
key(pageIndex) {
val spreadPageWidth = if (spreadPageCount > 1) {
val pageAspectRatio = displayPageRatios.getOrElse(pageIndex) { 1f }
with(density) {
pdfSpreadPageSlotWidth(
containerWidth = boxMaxWidthFloat,
containerHeight = boxMaxHeightFloat,
pageGap = spreadPageGapPx,
spreadPageCount = spreadPageCount,
pageAspectRatio = pageAspectRatio
).toDp()
}
} else {
with(density) { boxMaxWidthFloat.toDp() }
}
val isPageBookmarked by remember(bookmarks, pageIndex) {
derivedStateOf {
bookmarks.any { it.pageIndex == pageIndex }
@ -5130,7 +5158,7 @@ fun PdfViewerScreen(
ocrHoverHighlights = stableOcrRects,
modifier = if (spreadPageIndices.size > 1) {
Modifier
.weight(1f)
.width(spreadPageWidth)
.fillMaxHeight()
} else {
Modifier.fillMaxSize()
@ -6278,6 +6306,7 @@ fun PdfViewerScreen(
isReflowingThisBook = isReflowingThisBook,
hasReflowFile = hasReflowFile,
isPdfDocumentLoaded = pdfDocument != null,
canPrintDocument = !isPrintBlockedForPasswordProtectedPdf,
isTabsEnabled = isPdfTabStripVisible,
openTabs = openTabs,
activeTabBookId = activeTabBookId,
@ -7100,9 +7129,8 @@ fun PdfViewerScreen(
enter = fadeIn(),
exit = fadeOut()
) {
val percentage = (currentPageScale * 100).roundToInt()
ZoomPercentageIndicator(
percentage = percentage,
percentage = zoomIndicatorPercentage,
onResetZoomClick = {
resetZoomTrigger = System.currentTimeMillis()
}

View file

@ -3,6 +3,7 @@ package com.aryan.reader.pdf
import androidx.compose.ui.geometry.Offset
import com.aryan.reader.shared.pdf.PdfSpreadLayout
import com.aryan.reader.shared.reader.ReaderSettings
import kotlin.math.roundToInt
internal fun resolveEraserStrokeWidth(
isEraserOverride: Boolean,
@ -88,6 +89,22 @@ internal fun clampPdfSpreadCameraOffset(
)
}
internal fun pdfSpreadPageSlotWidth(
containerWidth: Float,
containerHeight: Float,
pageGap: Float,
spreadPageCount: Int,
pageAspectRatio: Float
): Float {
if (containerWidth <= 0f || containerHeight <= 0f || spreadPageCount <= 0) return 0f
val safeGap = pageGap.coerceAtLeast(0f)
val safeAspectRatio = pageAspectRatio.takeIf { it.isFinite() && it > 0f } ?: 1f
val availableWidth = (containerWidth - (safeGap * (spreadPageCount - 1))).coerceAtLeast(0f)
val maxPageWidth = availableWidth / spreadPageCount
val heightFittedPageWidth = containerHeight * safeAspectRatio
return heightFittedPageWidth.coerceAtMost(maxPageWidth).coerceAtLeast(0f)
}
internal fun activePdfCameraAfterLockPreferenceLoad(
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?
@ -139,3 +156,32 @@ internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
isZoomEnabled &&
!isScrollLocked
}
internal fun shouldRenderPdfHighResTiles(
effectiveScale: Float,
targetWidthPx: Int,
targetHeightPx: Int,
isVerticalScroll: Boolean,
isActivePage: Boolean,
largePageThresholdPx: Int = 3000,
verticalScaleTolerance: Float = 0.01f
): Boolean {
val hasLargePage = targetWidthPx > largePageThresholdPx || targetHeightPx > largePageThresholdPx
val isPageEligible = isVerticalScroll || isActivePage
if (!isPageEligible) return false
if (hasLargePage) return true
val safeScale = effectiveScale.takeIf { it.isFinite() && it > 0f } ?: 1f
return if (isVerticalScroll) {
kotlin.math.abs(safeScale - 1f) > verticalScaleTolerance
} else {
safeScale > 1f
}
}
internal fun pdfZoomIndicatorPercent(scale: Float): Int {
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
return (safeScale * 100f).roundToInt()
}
internal fun shouldShowPdfZoomIndicator(percentage: Int): Boolean = percentage != 100

View file

@ -32,11 +32,14 @@ import androidx.compose.foundation.layout.Spacer
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -69,6 +72,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.selected
@ -84,6 +88,7 @@ import com.aryan.reader.HexInput
import com.aryan.reader.R
import com.aryan.reader.RgbInputColumn
import com.aryan.reader.SpectrumBox
import com.aryan.reader.readerModalMaxHeightDp
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@ -151,18 +156,28 @@ fun ToolSettingsPopup(
}
val circleSize = 28.dp
val configuration = LocalConfiguration.current
val maxPopupHeight = readerModalMaxHeightDp(
screenHeightDp = configuration.screenHeightDp,
fraction = 0.8f,
verticalMarginDp = 64,
preferredMinHeightDp = 240
).dp
Surface(
modifier = modifier
.width(360.dp)
.padding(12.dp),
.padding(12.dp)
.heightIn(max = maxPopupHeight),
shape = RoundedCornerShape(28.dp),
color = Color(0xFF1E1E1E),
shadowElevation = 12.dp,
tonalElevation = 0.dp
) {
Column(
modifier = Modifier.padding(20.dp),
modifier = Modifier
.padding(20.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isEraser) {
@ -450,15 +465,21 @@ private fun ColorPickerDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
val configuration = LocalConfiguration.current
val maxDialogHeight = readerModalMaxHeightDp(configuration.screenHeightDp).dp
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.85f)
.padding(8.dp)
.heightIn(max = maxDialogHeight)
) {
Column(
modifier = Modifier.padding(20.dp),
modifier = Modifier
.padding(20.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
@ -788,4 +809,4 @@ private fun StyledPropertySlider(
)
}
}
}
}

View file

@ -176,6 +176,17 @@ class BaseTtsSynthesizer(private val context: Context) {
}
}
private suspend fun stopEngineForRetryLocked() {
Timber.w("BaseTts: Stopping current TTS utterance before retry.")
try {
tts?.stop()
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to stop TTS during retry recovery")
} finally {
delay(350)
}
}
private fun applyPreferredVoice() {
if (tts == null) return
@ -302,7 +313,7 @@ class BaseTtsSynthesizer(private val context: Context) {
requests.remove(utteranceId)
if (attempt < MAX_RETRY_ATTEMPTS) {
shutdownEngineLocked()
stopEngineForRetryLocked()
}
}
}

View file

@ -55,6 +55,15 @@ import com.aryan.reader.paginatedreader.TtsChunk
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
internal fun stableSortedIntSnapshot(values: Collection<Int>): List<Int> {
return try {
values.toTypedArray().sorted()
} catch (e: RuntimeException) {
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).w(e, "Failed to snapshot TTS cache keys")
emptyList()
}
}
val START_TTS_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.START")
val STOP_TTS_COMMAND: SessionCommand
@ -107,6 +116,7 @@ private const val TTS_NOTIFICATION_TRAILING_BUFFER_MS = 2_000L
private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L
private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L
private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1
internal const val MAX_CHUNK_GENERATION_FAILURES = 2
private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""")
private fun ttsSessionCommand(action: String): SessionCommand {
@ -143,9 +153,14 @@ internal fun resolveReusableTtsPlaylistIndex(
internal fun shouldAdvanceToTtsPlaylistChunk(
currentChunkIndex: Int,
playlistChunkIndex: Int?
playlistChunkIndex: Int?,
skippedChunkIndices: Set<Int> = emptySet()
): Boolean {
return playlistChunkIndex == currentChunkIndex + 1
return playlistChunkIndex == resolveNextPlayableTtsChunkIndex(
currentChunkIndex = currentChunkIndex,
totalChunks = maxOf(playlistChunkIndex?.plus(1) ?: 0, currentChunkIndex + 2),
skippedChunkIndices = skippedChunkIndices
)
}
internal fun shouldStartTtsTransitionPrefetch(
@ -162,6 +177,22 @@ internal fun shouldStopTtsPrefetchAfterMissingChunk(
return !isLoaded && playlistIndex == null
}
internal fun resolveNextPlayableTtsChunkIndex(
currentChunkIndex: Int,
totalChunks: Int,
skippedChunkIndices: Set<Int>
): Int? {
if (totalChunks <= 0 || currentChunkIndex !in -1 until totalChunks) return null
return ((currentChunkIndex + 1) until totalChunks).firstOrNull { it !in skippedChunkIndices }
}
internal fun shouldGiveUpTtsChunkGeneration(
failureCount: Int,
maxFailures: Int = MAX_CHUNK_GENERATION_FAILURES
): Boolean {
return failureCount >= maxFailures
}
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)
@ -241,6 +272,8 @@ class TtsPlaybackManager(
private var currentAuthToken: String? = null
private val loadedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>()
private val skippedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
private val chunkGenerationFailures = java.util.concurrent.ConcurrentHashMap<Int, AtomicInteger>()
enum class TtsMode {
CLOUD, BASE
@ -346,7 +379,7 @@ class TtsPlaybackManager(
}
private fun cancelPrefetchWork() {
logChunkNav("prefetch-cancel", "activePrefetching=${prefetchingJobs.keys.sorted()} lastPrefetch=$lastPrefetchIndex")
logChunkNav("prefetch-cancel", "activePrefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)} lastPrefetch=$lastPrefetchIndex")
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
@ -386,7 +419,7 @@ class TtsPlaybackManager(
}
private fun cacheSnapshot(): String {
return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${loadedChunks.sorted()} audio=${audioFiles.keys.sorted()} streams=${chunkStreamIds.keys.sorted()} prefetching=${prefetchingJobs.keys.sorted()}"
return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${stableSortedIntSnapshot(loadedChunks)} skipped=${stableSortedIntSnapshot(skippedChunks)} audio=${stableSortedIntSnapshot(audioFiles.keys)} streams=${stableSortedIntSnapshot(chunkStreamIds.keys)} prefetching=${stableSortedIntSnapshot(prefetchingJobs.keys)}"
}
override fun onConnect(
@ -826,6 +859,8 @@ class TtsPlaybackManager(
this.pageIndex = pageIndex
loadedChunks.clear()
skippedChunks.clear()
chunkGenerationFailures.clear()
lastPrefetchIndex = -1
_ttsState.value = TtsState(
@ -914,7 +949,11 @@ class TtsPlaybackManager(
}
private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean {
val nextChunkIndex = resolveTtsChunkSkipTarget(currentChunkIndex, textChunks.size, direction = 1)
val nextPlayableChunkIndex = resolveNextPlayableTtsChunkIndex(
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
skippedChunkIndices = skippedChunks
)
?: run {
logChunkNavMain(
"advance-next-no-target",
@ -922,16 +961,16 @@ class TtsPlaybackManager(
)
return false
}
val nextPlaylistIndex = findPlaylistIndexForChunk(nextChunkIndex)
val nextPlaylistIndex = findPlaylistIndexForChunk(nextPlayableChunkIndex)
?: run {
logChunkNavMain(
"advance-next-missing-playlist-item",
"currentChunk=$currentChunkIndex expectedNextChunk=$nextChunkIndex"
"currentChunk=$currentChunkIndex expectedNextChunk=$nextPlayableChunkIndex skipped=${stableSortedIntSnapshot(skippedChunks)}"
)
return false
}
val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull()
if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex)) {
if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex, skippedChunks)) {
logChunkNavWarnMain(
"advance-next-refused-non-contiguous",
"Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex"
@ -940,7 +979,7 @@ class TtsPlaybackManager(
}
logChunkNavMain(
"advance-next-seek",
"currentChunk=$currentChunkIndex nextChunk=$nextChunkIndex nextPlaylistIndex=$nextPlaylistIndex"
"currentChunk=$currentChunkIndex nextChunk=$nextPlayableChunkIndex nextPlaylistIndex=$nextPlaylistIndex"
)
player.seekTo(nextPlaylistIndex, 0L)
return true
@ -1028,6 +1067,8 @@ class TtsPlaybackManager(
audioFiles.clear()
chunkStreamIds.clear()
loadedChunks.clear()
skippedChunks.clear()
chunkGenerationFailures.clear()
lastPrefetchIndex = -1
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
@ -1097,6 +1138,8 @@ class TtsPlaybackManager(
val serverText = ttsAudioData.serverText
if ((audioFile != null || streamUri != null) && serverText != null) {
chunkGenerationFailures.remove(startAtIndex)
skippedChunks.remove(startAtIndex)
if (audioFile != null) {
audioFiles[startAtIndex] = audioFile
}
@ -1167,10 +1210,30 @@ class TtsPlaybackManager(
prefetchNextChunkAudio(startAtIndex)
}
} else {
val failureCount = recordChunkGenerationFailure(startAtIndex)
logChunkNav(
"prepare-first-failed",
"chunk=$startAtIndex error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
"chunk=$startAtIndex failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
)
val nextPlayableChunk = resolveNextPlayableTtsChunkIndex(
currentChunkIndex = startAtIndex,
totalChunks = textChunks.size,
skippedChunkIndices = skippedChunks + startAtIndex
)
if (shouldGiveUpTtsChunkGeneration(failureCount) && nextPlayableChunk != null) {
skippedChunks.add(startAtIndex)
logChunkNav(
"prepare-first-skip-failed-chunk",
"chunk=$startAtIndex nextChunk=$nextPlayableChunk failureCount=$failureCount"
)
prepareAndPlayFirstChunk(
startAtIndex = nextPlayableChunk,
playWhenReady = playWhenReady,
startAtPosition = 0L,
prefetchAfterPrepare = prefetchAfterPrepare
)
return
}
_ttsState.value = _ttsState.value.copy(
isLoading = false,
errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio)
@ -1243,6 +1306,8 @@ class TtsPlaybackManager(
pageIndex = null
cancelPrefetchWork()
loadedChunks.clear()
skippedChunks.clear()
chunkGenerationFailures.clear()
scope.launch {
clearAudioFiles()
@ -1434,6 +1499,10 @@ class TtsPlaybackManager(
}
val targetIndex = currentIndex + i
if (targetIndex < textChunks.size) {
if (skippedChunks.contains(targetIndex)) {
logChunkNav("prefetch-target-skip-marked", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
continue
}
if (prefetchingJobs.containsKey(targetIndex)) {
logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
continue
@ -1496,6 +1565,8 @@ class TtsPlaybackManager(
val serverText = ttsAudioData.serverText
if ((audioFile != null || streamUri != null) && serverText != null) {
chunkGenerationFailures.remove(targetIndex)
skippedChunks.remove(targetIndex)
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
val pathToUse = streamUri ?: audioFile!!.absolutePath
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
@ -1560,7 +1631,11 @@ class TtsPlaybackManager(
}
val currentChunkIndex = currentChunkIndexFromPlayer()
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
val isImmediateNextChunk = targetIndex == resolveNextPlayableTtsChunkIndex(
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
skippedChunkIndices = skippedChunks
)
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
logChunkNavMain(
@ -1579,11 +1654,19 @@ class TtsPlaybackManager(
}
}
} else {
val failureCount = recordChunkGenerationFailure(targetIndex)
Timber.e("Prefetch: Failed to download chunk $targetIndex")
logChunkNav(
"prefetch-generate-failed",
"targetChunk=$targetIndex generation=$generation error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
"targetChunk=$targetIndex generation=$generation failureCount=$failureCount error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
)
if (shouldGiveUpTtsChunkGeneration(failureCount)) {
skippedChunks.add(targetIndex)
logChunkNav(
"prefetch-skip-failed-chunk",
"targetChunk=$targetIndex generation=$generation failureCount=$failureCount"
)
}
}
}
prefetchingJobs[targetIndex] = job
@ -1599,6 +1682,13 @@ class TtsPlaybackManager(
)
return@launch
}
if (skippedChunks.contains(targetIndex)) {
logChunkNav(
"prefetch-after-join-skipped",
"targetChunk=$targetIndex generation=$generation"
)
continue
}
val shouldStopAfterMissingChunk = withContext(Dispatchers.Main) {
val playlistIndex = findPlaylistIndexForChunk(targetIndex)
shouldStopTtsPrefetchAfterMissingChunk(
@ -1625,6 +1715,12 @@ class TtsPlaybackManager(
}
}
private fun recordChunkGenerationFailure(chunkIndex: Int): Int {
return chunkGenerationFailures
.getOrPut(chunkIndex) { AtomicInteger(0) }
.incrementAndGet()
}
private suspend fun trackWordByWord() {
var loopCount = 0
while (true) {
@ -1821,6 +1917,8 @@ class TtsPlaybackManager(
chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED
chunkStreamIds.clear() // ADDED
loadedChunks.clear()
skippedChunks.clear()
chunkGenerationFailures.clear()
}
}

View file

@ -22,99 +22,99 @@
</plurals>
<plurals name="shelf_count">
<item quantity="one">%1$d riiul</item>
<item quantity="other">%1$d riiulid</item>
<item quantity="other">%1$d riiulit</item>
</plurals>
<plurals name="dialog_permanently_delete_desc">
<item quantity="one">Kas soovite %1$d jäädavalt kustutada valitud faili oma seadmest? Seda toimingut ei saa tagasi võtta.</item>
<item quantity="other">Kas soovite %1$d jäädavalt kustutada teie seadmest valitud failid? Seda toimingut ei saa tagasi võtta.</item>
<item quantity="one">Kas kustutada %1$d valitud fail seadmest jäädavalt? Seda toimingut ei saa tagasi võtta.</item>
<item quantity="other">Kas kustutada %1$d valitud faili seadmest jäädavalt? Seda toimingut ei saa tagasi võtta.</item>
</plurals>
<plurals name="dialog_remove_recents_desc">
<item quantity="one">Kas soovite eemaldada %1$d valitud faili viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust.</item>
<item quantity="other">Kas soovite eemaldada %1$d valitud failid viimaste failide loendist? See kuvatakse uuesti, kui avate selle uuesti raamatukogust.</item>
<item quantity="one">Kas eemaldada %1$d valitud fail viimaste failide loendist? See ilmub uuesti, kui avad selle raamatukogust.</item>
<item quantity="other">Kas eemaldada %1$d valitud faili viimaste failide loendist? Need ilmuvad uuesti, kui avad need raamatukogust.</item>
</plurals>
<plurals name="dialog_remove_from_shelf_desc">
<item quantity="one">Kas soovite kindlasti eemaldada %1$d raamat \'%2$s\' riiul? Raamat jääb teie kogusse ja kuvatakse jaotises Riiulita.</item>
<item quantity="other">Kas soovite kindlasti eemaldada %1$d raamatud \'%2$s\' riiul? Raamatud jäävad teie kogusse ja kuvatakse jaotises Riiulita.</item>
<item quantity="one">Kas eemaldada %1$d raamat riiulilt &quot;%2$s&quot;? Raamat jääb raamatukokku ja kuvatakse riiulita raamatute all.</item>
<item quantity="other">Kas eemaldada %1$d raamatut riiulilt &quot;%2$s&quot;? Raamatud jäävad raamatukokku ja kuvatakse riiulita raamatute all.</item>
</plurals>
<plurals name="banner_books_removed_library">
<item quantity="one">%1$d raamat raamatukogust eemaldatud.</item>
<item quantity="other">%1$d raamatud raamatukogust eemaldatud.</item>
<item quantity="other">%1$d raamatut eemaldati raamatukogust.</item>
</plurals>
<plurals name="banner_importing_books_count">
<item quantity="one">Importimine %1$d raamat… See ilmub peagi teie teegis.</item>
<item quantity="other">Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse.</item>
<item quantity="one">Impordin %1$d raamatut… See ilmub peagi raamatukogusse.</item>
<item quantity="other">Impordin %1$d raamatut… Need ilmuvad peagi raamatukogusse.</item>
</plurals>
<plurals name="banner_books_imported_library_tab">
<item quantity="one">Imporditud %1$d raamat. Selle leiate vahekaardilt Raamatukogu.</item>
<item quantity="other">Imporditud %1$d raamatuid. Leiate need vahekaardilt Raamatukogu.</item>
<item quantity="one">Imporditi %1$d raamat. Leiad selle vahekaardilt Raamatukogu.</item>
<item quantity="other">Imporditi %1$d raamatut. Leiad need vahekaardilt Raamatukogu.</item>
</plurals>
<plurals name="banner_books_added_to_shelf">
<item quantity="one">%1$d raamat lisatud riiulile.</item>
<item quantity="other">%1$d raamatud lisatud riiulile.</item>
<item quantity="other">%1$d raamatut lisati riiulile.</item>
</plurals>
<plurals name="banner_books_tagged_with_tag">
<item quantity="one">%1$d raamat sildiga &quot;%2$s&quot;.</item>
<item quantity="other">%1$d raamatud sildiga &quot;%2$s&quot;.</item>
<item quantity="other">%1$d raamatut märgiti sildiga &quot;%2$s&quot;.</item>
</plurals>
<plurals name="banner_folder_removed_with_book_count">
<item quantity="one">Eemaldatud kaust &quot;%1$s&quot; ja %2$d raamat rakendusest.</item>
<item quantity="other">Eemaldatud kaust &quot;%1$s&quot; ja %2$d raamatud rakendusest.</item>
<item quantity="one">Eemaldati kaust &quot;%1$s&quot; ja %2$d raamat rakendusest.</item>
<item quantity="other">Eemaldati kaust &quot;%1$s&quot; ja %2$d raamatut rakendusest.</item>
</plurals>
<plurals name="folder_count">
<item quantity="one">%1$d kausta</item>
<item quantity="other">%1$d kaustad</item>
<item quantity="one">%1$d kaust</item>
<item quantity="other">%1$d kausta</item>
</plurals>
<plurals name="file_count">
<item quantity="one">%1$d faili</item>
<item quantity="other">%1$d failid</item>
<item quantity="one">%1$d fail</item>
<item quantity="other">%1$d faili</item>
</plurals>
<plurals name="desktop_drop_import_file_count">
<item quantity="one">Langetage import %1$d faili</item>
<item quantity="other">Langetage import %1$d failid</item>
<item quantity="one">Lohista importimiseks %1$d fail</item>
<item quantity="other">Lohista importimiseks %1$d faili</item>
</plurals>
<plurals name="desktop_unsupported_import_file_count">
<item quantity="one">%1$d toetamata fail jäetakse vahele.</item>
<item quantity="other">%1$d toetamata failid jäetakse vahele.</item>
<item quantity="other">%1$d toetamata faili jäetakse vahele.</item>
</plurals>
<plurals name="desktop_importing_file_count">
<item quantity="one">Importimine %1$d fail</item>
<item quantity="other">Importimine %1$d failid</item>
<item quantity="one">Impordin %1$d faili</item>
<item quantity="other">Impordin %1$d faili</item>
</plurals>
<plurals name="desktop_imported_file_count">
<item quantity="one">Imporditud %1$d faili.</item>
<item quantity="other">Imporditud %1$d failid.</item>
<item quantity="one">Imporditi %1$d fail.</item>
<item quantity="other">Imporditi %1$d faili.</item>
</plurals>
<plurals name="desktop_imported_file_count_reader_support_later">
<item quantity="one">Imporditud %1$d faili. Lugeja tugi tuleb hiljem.</item>
<item quantity="other">Imporditud %1$d failid. Lugeja tugi tuleb hiljem.</item>
<item quantity="one">Imporditi %1$d fail. Lugeja tugi tuleb hiljem.</item>
<item quantity="other">Imporditi %1$d faili. Lugeja tugi tuleb hiljem.</item>
</plurals>
<plurals name="desktop_import_failed_file_count">
<item quantity="one">Ei saanud importida %1$d faili.</item>
<item quantity="other">Ei saanud importida %1$d failid.</item>
<item quantity="one">%1$d faili importimine nurjus.</item>
<item quantity="other">%1$d faili importimine nurjus.</item>
</plurals>
<plurals name="desktop_skipped_file_count">
<item quantity="one">Vahele jäetud %1$d faili.</item>
<item quantity="other">Vahele jäetud %1$d failid.</item>
<item quantity="one">%1$d fail jäeti vahele.</item>
<item quantity="other">%1$d faili jäeti vahele.</item>
</plurals>
<plurals name="desktop_remove_folder_desc_with_book_count">
<item quantity="one">Eemalda &quot;%1$s&quot; ja selle %2$d raamatut rakendusest? Ketta faile ei kustutata.</item>
<item quantity="other">Eemalda &quot;%1$s&quot; ja selle %2$d raamatud rakendusest? Ketta faile ei kustutata.</item>
<item quantity="one">Kas eemaldada &quot;%1$s&quot; ja selle %2$d raamat rakendusest? Kettal olevaid faile ei kustutata.</item>
<item quantity="other">Kas eemaldada &quot;%1$s&quot; ja selle %2$d raamatut rakendusest? Kettal olevaid faile ei kustutata.</item>
</plurals>
<plurals name="desktop_folder_sync_failed_folder_count">
<item quantity="one">Kausta sünkroonimine ebaõnnestus %1$d kausta.</item>
<item quantity="other">Kausta sünkroonimine ebaõnnestus %1$d kaustad.</item>
<item quantity="one">%1$d kausta sünkroonimine nurjus.</item>
<item quantity="other">%1$d kausta sünkroonimine nurjus.</item>
</plurals>
<plurals name="desktop_folder_sync_skipped_folder_count">
<item quantity="one">Kausta sünkroonimine on lõpetatud %1$d kaust jäi vahele.</item>
<item quantity="other">Kausta sünkroonimine on lõpetatud %1$d kaustad vahele jäetud.</item>
<item quantity="one">Kausta sünkroonimine lõppes, %1$d kaust jäeti vahele.</item>
<item quantity="other">Kausta sünkroonimine lõppes, %1$d kausta jäeti vahele.</item>
</plurals>
<plurals name="desktop_opds_removed_stream_book_count">
<item quantity="one">Eemaldatud %1$d voogesitatud OPDS raamat sellest kataloogist.</item>
<item quantity="other">Eemaldatud %1$d voogesitatud OPDS raamatud sellest kataloogist.</item>
<item quantity="one">Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamat.</item>
<item quantity="other">Sellest kataloogist eemaldati %1$d voogedastatud OPDS-raamatut.</item>
</plurals>
<plurals name="tag_count">
<item quantity="one">%1$d tag</item>
<item quantity="other">%1$d sildid</item>
<item quantity="one">%1$d silt</item>
<item quantity="other">%1$d silti</item>
</plurals>
<plurals name="desktop_library_tab_books_count">
<item quantity="one">Kõik raamatud %1$d</item>
@ -133,7 +133,7 @@
<item quantity="other">Kaustad %1$d</item>
</plurals>
<plurals name="tts_cache_chunk_count_parenthetical">
<item quantity="one">(%1$d tükk)</item>
<item quantity="other">(%1$d tükid)</item>
<item quantity="one">(%1$d osa)</item>
<item quantity="other">(%1$d osa)</item>
</plurals>
</resources>

View file

@ -4,25 +4,25 @@
<string name="action_save">Salvesta</string>
<string name="action_delete">Kustuta</string>
<string name="action_remove">Eemalda</string>
<string name="action_ok">Sobib</string>
<string name="action_ok">OK</string>
<string name="action_close">Sulge</string>
<string name="action_add">Lisa</string>
<string name="action_rename">Muuda nime</string>
<string name="action_back">Tagasi</string>
<string name="action_search">Otsi</string>
<string name="action_clear">Selge</string>
<string name="action_clear">Tühjenda</string>
<string name="action_apply">Rakenda</string>
<string name="action_enable">Luba</string>
<string name="error_message_format">Viga: %1$s</string>
<string name="action_go_back">Mine tagasi</string>
<string name="tab_free">Tasuta</string>
<string name="tab_free">Vaba</string>
<string name="active_tabs">Aktiivsed vahelehed</string>
<string name="pdf_tabs_show_top_app_bar_tabs">Kuva vahekaardid ülemisel rakenduseribal</string>
<string name="close_tab">Sule vahekaart</string>
<string name="close_all_tabs">Sulgege kõik vahelehed</string>
<string name="close_all_tabs">Sulge kõik vahelehed</string>
<string name="dialog_close_all_tabs">Kas sulgeda kõik vahelehed?</string>
<string name="dialog_close_all_tabs_desc">Kas olete kindel, et soovite sulgeda kõik aktiivsed vahelehed?</string>
<string name="legal_agreement_full">%1$s nõustute meie %2$s ja kinnitage, et olete lugenud meie %3$s.</string>
<string name="dialog_close_all_tabs_desc">Kas sulgeda kõik aktiivsed vahelehed?</string>
<string name="legal_agreement_full">%1$s nõustud meie %2$s ja kinnitad, et oled lugenud meie %3$s.</string>
<string name="legal_terms_of_service">Kasutustingimused</string>
<string name="legal_privacy_policy">Privaatsuspoliitika</string>
<string name="legal_licenses">Litsentsid</string>
@ -30,58 +30,58 @@
<string name="clear_selection">Tühjenda valik</string>
<string name="pin_unpin">Kinnita/vabasta</string>
<string name="info">Info</string>
<string name="select_all">Valige Kõik</string>
<string name="select_all">Vali kõik</string>
<string name="dialog_remove_from_recents">Eemalda hiljutiste hulgast</string>
<string name="dialog_warning_folder_sync_delete">Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone.</string>
<string name="dialog_warning_folder_sync_delete">Hoiatus: osa valitud üksusi on sünkroonitud kohalikust kaustast. Jätkamisel kustutatakse tegelikud failid seadme mälust.\n\nSeda toimingut ei saa tagasi võtta.</string>
<string name="file_information">Faili teave</string>
<string name="book_name">Raamatu nimi</string>
<string name="copy_name">Kopeeri nimi</string>
<string name="original_name">Algne nimi: %1$s</string>
<string name="revert_to_original">Taastage originaal</string>
<string name="revert_to_original">Taasta algne nimi</string>
<string name="file_name">Faili nimi: %1$s</string>
<string name="author">Autor</string>
<string name="format">Vorming</string>
<string name="size">Suurus</string>
<string name="added">Lisatud</string>
<string name="location">Asukoht</string>
<string name="source_opds">Allikas: OPDS Voog</string>
<string name="source_opds">Allikas: OPDS-voog</string>
<string name="source_in_app">Rakendusesisene salvestusruum</string>
<string name="internal_storage">Sisemälu</string>
<string name="about_title">Umbes Episteme</string>
<string name="about_version">Versioon: %1$s (Järgmine: %2$d)</string>
<string name="empty_select_file">Valige fail</string>
<string name="about_title">Teave Episteme kohta</string>
<string name="about_version">Versioon: %1$s (järk: %2$d)</string>
<string name="empty_select_file">Vali fail</string>
<string name="clear_cloud_data_title">Kas kustutada kõik sünkroonitud andmed?</string>
<string name="clear_cloud_data_desc">Kas olete kindel, et soovite kõik oma raamatuandmed pilvest jäädavalt kustutada? See kustutab uuesti sünkroonimise vältimiseks ka teie kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta.</string>
<string name="clear_cloud_data_desc">Kas kustutada kõik raamatuandmed pilvest jäädavalt? See tühjendab uuesti sünkroonimise vältimiseks ka kohaliku raamatukogu. Seda toimingut ei saa tagasi võtta.</string>
<string name="delete_all_data">KUSTUTA KÕIK ANDMED</string>
<string name="nav_home">Kodu</string>
<string name="nav_library">Raamatukogu</string>
<string name="recent_files">Viimased failid</string>
<string name="your_library_empty">Teie raamatukogu on tühi</string>
<string name="your_library_empty_desc">Valige lugemiseks fail või sünkroonige kohalik kaust, et raamatuid automaatselt importida.</string>
<string name="your_library_empty">Raamatukogu on tühi</string>
<string name="your_library_empty_desc">Vali lugemiseks fail või sünkrooni kohalik kaust, et raamatud automaatselt importida.</string>
<string name="no_recent_files">Viimaseid faile pole</string>
<string name="no_recent_files_desc">Avage fail oma teegist, et seda siin näha.</string>
<string name="no_recent_files_desc">Ava fail raamatukogust, et seda siin näha.</string>
<string name="setup_folder_sync">Kausta sünkroonimise seadistamine</string>
<string name="sync_folder">Sünkrooni kaust</string>
<string name="local_folder">Kohalik kaust</string>
<string name="pinned">Kinnitatud</string>
<string name="progress_complete">%1$d%% täielik</string>
<string name="not_available_locally">Pole kohapeal saadaval</string>
<string name="drawer_sign_in">Logige sisse rakendusega Google</string>
<string name="drawer_sign_in">Logi Googleiga sisse</string>
<string name="drawer_by_signing_in">Sisse logides</string>
<string name="drawer_pro_unlocked">Episteme Pro</string>
<string name="drawer_upgrade_pro">Uuenda versioonile Episteme Pro</string>
<string name="drawer_sync_library">Sünkrooni raamatukogu</string>
<string name="drawer_backup_local_folders">Pilvesünkroonimine kohalike kaustade jaoks</string>
<string name="drawer_backup_desc">Laadige raamatud oma sünkroonitud kaustadest üles kausta Google Drive.</string>
<string name="drawer_backup_desc">Laadi sünkroonitud kaustade raamatud Google Drivei üles.</string>
<string name="drawer_custom_fonts">Kohandatud fondid</string>
<string name="drawer_support_project">Toetage projekti</string>
<string name="drawer_support_project">Toeta projekti</string>
<string name="drawer_help_feedback">Abi ja tagasiside</string>
<string name="drawer_sign_out">Logi välja</string>
<string name="options_recent_limit">Viimaste failide limiit</string>
<string name="options_no_limit">Piiramata</string>
<string name="options_files_limit">%1$d failid</string>
<string name="options_clear_book_cache">Tühjenda raamatu vahemälu</string>
<string name="options_clear_reflow_cache">Tühjendage reflow vahemälu</string>
<string name="options_clear_reflow_cache">Tühjenda reflow-vahemälu</string>
<string name="library_title">Raamatukogu</string>
<string name="search_placeholder">Otsi pealkirja või autorit…</string>
<string name="filter_types">Tüübid: %1$s</string>
@ -92,28 +92,28 @@
<string name="tab_folders">Kaustad</string>
<string name="tab_catalogs">Kataloogid</string>
<string name="no_results_found">Päringule \&quot;%1$s\&quot; ei leitud tulemusi</string>
<string name="library_empty_desc">Valige PDF, EPUB, MOBI või AZW3 alustamiseks oma seadmest faili.</string>
<string name="library_empty_desc">Alustamiseks vali seadmest PDF-, EPUB-, MOBI- või AZW3-fail.</string>
<string name="fab_add_file">Lisa fail</string>
<string name="fab_new_shelf">Uus riiul</string>
<string name="create_new_shelf">Looge uus riiul</string>
<string name="create_new_shelf">Loo uus riiul</string>
<string name="shelf_name_hint">Riiuli nimi</string>
<string name="action_create">Loo</string>
<string name="menu_rename_shelf">Nimetage riiul ümber</string>
<string name="menu_rename_shelf">Nimeta riiul ümber</string>
<string name="menu_delete_shelf">Kustuta riiul</string>
<string name="fab_add_books">Lisage raamatuid</string>
<string name="fab_add_books">Lisa raamatuid</string>
<string name="shelf_empty">See riiul on tühi</string>
<string name="add_to_shelf">Lisa %1$s</string>
<string name="fab_add_count">LISA (%1$d)</string>
<string name="no_unshelved_books">Pole ühtegi riiulita raamatut, mida lisada</string>
<string name="all_books_in_shelf">Kõik raamatud on juba sellel riiulil</string>
<string name="dialog_rename_shelf">Nimetage riiul ümber</string>
<string name="dialog_rename_shelf">Nimeta riiul ümber</string>
<string name="dialog_delete_shelf">Kas kustutada riiul?</string>
<string name="dialog_delete_shelf_desc">Kas soovite kindlasti kustutada faili \'%1$s\' riiul? Kõik raamatud teisaldatakse riiulitele.</string>
<string name="dialog_delete_shelf_desc">Kas kustutada riiul &quot;%1$s&quot;? Kõik raamatud teisaldatakse riiulita raamatute alla.</string>
<string name="dialog_remove_from_shelf">Kas eemaldada riiulist?</string>
<string name="dialog_delete_shelves">Kustuta %1$s?</string>
<string name="dialog_delete_shelves_desc">Kas soovite kindlasti kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse jaotisesse Riiulita.</string>
<string name="sync_local_folders">Sünkroonige kohalikud kaustad</string>
<string name="sync_folders_desc">Reaalajas teegi loomiseks ühendage kohalikud kaustad. Episteme jälgib faile ja sünkroonimise edenemist.</string>
<string name="dialog_delete_shelves_desc">Kas kustutada %1$d valitud %2$s? Kõik sees olevad raamatud teisaldatakse riiulita raamatute alla.</string>
<string name="sync_local_folders">Sünkrooni kohalikud kaustad</string>
<string name="sync_folders_desc">Ühenda kohalikud kaustad, et luua reaalajas raamatukogu. Episteme jälgib faile ja sünkroonib lugemisjärge.</string>
<string name="fab_add_folder">Lisa kaust</string>
<string name="scan_all">Skanni kõik</string>
<string name="scanning">Skannimine…</string>
@ -126,24 +126,24 @@
<string name="menu_enable_folder_local_sync">Luba kohalik sünkroonimine</string>
<string name="folder_local_sync_disabled">Kohalik sünkroonimine on keelatud</string>
<string name="dialog_disable_folder_local_sync_title">Kas keelata kohaliku kausta sünkroonimine?</string>
<string name="dialog_disable_folder_local_sync_desc">Episteme lõpetab selle kausta skannimise ja kirjutamise JSON failide sünkroonimine. Eemaldage %1$s kaust ka sellest kaustast?</string>
<string name="action_disable_keep_sync_data">Sünkrooni andmed</string>
<string name="action_disable_remove_sync_data">Eemaldage sünkroonimisandmed</string>
<string name="dialog_disable_folder_local_sync_desc">Episteme lõpetab selle kausta skannimise ja sünkroonimise JSON-failidesse kirjutamise. Kas eemaldada kaustast ka %1$s kaust?</string>
<string name="action_disable_keep_sync_data">Hoia sünkroonimisandmed</string>
<string name="action_disable_remove_sync_data">Eemalda sünkroonimisandmed</string>
<string name="filter_file_types">Filtreeri failitüübid</string>
<string name="filter_file_types_desc">Valige failitüübid, mida soovite sellest kaustast sünkroonida:</string>
<string name="filter_library">Filtri raamatukogu</string>
<string name="filter_file_types_desc">Vali failitüübid, mida sellest kaustast sünkroonida:</string>
<string name="filter_library">Filtreeri raamatukogu</string>
<string name="filter_file_type">Faili tüüp</string>
<string name="filter_source_folder">Allikakaust</string>
<string name="filter_read_status">Loe olekut</string>
<string name="clear_all">Kustuta kõik</string>
<string name="filter_read_status">Lugemise olek</string>
<string name="clear_all">Tühjenda kõik</string>
<string name="filter_in_app_storage">Rakendusesisene salvestusruum</string>
<string name="external_file_prompt_title">Kas salvestada fail?</string>
<string name="external_file_prompt_desc">Do you want to save this external file in the app\'s library? If not, it will be removed.\n\n(You can change this default behavior anytime from the Home Screen &gt; More Options &gt; External File Behavior).</string>
<string name="external_file_dont_ask">Ära\'ära küsi uuesti</string>
<string name="external_file_keep">Hoidke raamatukogus</string>
<string name="external_file_prompt_desc">Kas salvestada see väline fail rakenduse raamatukokku? Kui mitte, eemaldatakse see.\n\n(Seda vaikekäitumist saad igal ajal muuta: avakuva &gt; Rohkem valikuid &gt; Välise faili käitumine.)</string>
<string name="external_file_dont_ask">Ära küsi uuesti</string>
<string name="external_file_keep">Hoia raamatukogus</string>
<string name="external_file_delete">Eemalda</string>
<string name="external_file_behavior_ask">Küsi iga kord</string>
<string name="external_file_behavior_keep">Hoidke alati</string>
<string name="external_file_behavior_keep">Hoia alati</string>
<string name="external_file_behavior_delete">Eemalda alati</string>
<string name="options_external_file_behavior">Välise faili käitumine</string>
<string name="fab_add_catalog">Lisa kataloog</string>
@ -155,29 +155,29 @@
<string name="action_unavailable">Pole saadaval</string>
<string name="action_download">Laadi alla</string>
<string name="download_format">Laadi alla vorming</string>
<string name="action_stream_now">Voogesitage kohe</string>
<string name="action_read">Lugege</string>
<string name="action_stream_now">Voogedasta kohe</string>
<string name="action_read">Loe</string>
<string name="no_supported_formats">Toetatud vorminguid pole saadaval.</string>
<string name="publisher">VÄLJAANDJA</string>
<string name="published">AVALDATUD</string>
<string name="language">KEEL</string>
<string name="synopsis">Sisukokkuvõte</string>
<string name="edit_catalog">Redigeeri kataloogi</string>
<string name="add_opds_catalog">Lisa OPDS Kataloog</string>
<string name="add_opds_catalog">Lisa OPDS-kataloog</string>
<string name="catalog_name">Kataloogi nimi</string>
<string name="url">URL</string>
<string name="auth_optional">Autentimine (valikuline)</string>
<string name="username">Kasutajanimi</string>
<string name="password">Parool</string>
<string name="delete_catalog">Kustuta kataloog</string>
<string name="delete_catalog_desc">Kas soovite kindlasti kustutada \'%1$s\'?</string>
<string name="delete_catalog_warning">Selle kataloogi kustutamisel eemaldatakse jäädavalt ka %1$d sellega seotud raamatute voogesitamine teie kogust.</string>
<string name="delete_catalog_desc">Kas oled kindel, et soovid kustutada \'%1$s\'?</string>
<string name="delete_catalog_warning">Selle kataloogi kustutamisel eemaldatakse raamatukogust jäädavalt ka %1$d sellega seotud voogedastatud raamatut.</string>
<string name="preset_label">Eelseadistatud</string>
<string name="free_plan">Tasuta plaan</string>
<string name="forever_free">Igavesti tasuta</string>
<string name="feature_multiple_formats">Mitu vormingut</string>
<string name="feature_multiple_formats_desc">Toed PDF, EPUB, MOBI, AZW3</string>
<string name="feature_tts">Android Tekst kõneks</string>
<string name="feature_multiple_formats_desc">Toetab vorminguid PDF, EPUB, MOBI ja AZW3</string>
<string name="feature_tts">Androidi tekst kõneks</string>
<string name="feature_tts_desc">Kuulake oma raamatuid sisseehitatud TTS</string>
<string name="feature_dict">Põhisõnastik</string>
<string name="feature_dict_desc">Otsige kiiresti üles üksikud sõnad</string>
@ -188,25 +188,25 @@
<string name="early_access_sale">Varajase juurdepääsu müük</string>
<string name="pro_includes">Omadused:</string>
<string name="feature_cloud_sync">Pilvesünkroonimine seadmete vahel</string>
<string name="feature_cloud_sync_desc">Hoidke kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes.</string>
<string name="feature_cloud_sync_desc">Hoia kogu oma kogu, sealhulgas raamatufailid ja lugemised, sünkroonituna kuni neljas seadmes.</string>
<string name="feature_summarize">Kokkuvõte</string>
<string name="feature_summarize_desc">Saate päevas 10 tasuta kokkuvõtet peatükkide või lehtede kohta</string>
<string name="feature_smart_dict">Nutikas sõnastik</string>
<string name="feature_smart_dict_desc">Otsige fraase ja isegi lõike, mitte ainult üksikuid sõnu</string>
<string name="feature_priority">Prioriteetsete funktsioonide taotlused</string>
<string name="feature_priority_desc">Teie ettepanekud seatakse prioriteediks</string>
<string name="feature_priority_desc">Sinu ettepanekud seatakse prioriteediks</string>
<string name="pro_unlocked">Pro funktsioonid on lukustamata!</string>
<string name="sign_in_required">Sisselogimine Nõutav</string>
<string name="verifying_purchase">Ostu kinnitamine…</string>
<string name="existing_purchase_found">Olemasolev ost leitud</string>
<string name="get_lifetime_access">Hankige eluaegne juurdepääs</string>
<string name="get_lifetime_access">Hangi eluaegne juurdepääs</string>
<string name="upgrade_unavailable">Uuendamine pole praegu saadaval. Kontrollige oma Internetti ja proovige uuesti.</string>
<string name="sign_in_to_purchase">Logige sisse oma Google konto ostmiseks Episteme Pro.</string>
<string name="sign_in_to_purchase_credits">Logige sisse oma Google konto krediidi ostmiseks.</string>
<string name="verifying_purchase_desc">See võib võtta mõne hetke. Teie Pro staatust värskendatakse automaatselt.</string>
<string name="dialog_existing_purchase_desc">Sellel seadmel on juba Pro-ost, kuid see\' on lingitud teise kontoga. Pro funktsioonide taastamiseks logige sisse kontole, mida kasutati algsel ostul.</string>
<string name="sign_in_to_purchase">Logi sisse oma Google konto ostmiseks Episteme Pro.</string>
<string name="sign_in_to_purchase_credits">Logi sisse oma Google konto krediidi ostmiseks.</string>
<string name="verifying_purchase_desc">See võib võtta mõne hetke. Pro-olekut värskendatakse automaatselt.</string>
<string name="dialog_existing_purchase_desc">Selles seadmes on juba Pro-ost, kuid see on seotud teise kontoga. Pro-funktsioonide taastamiseks logi sisse kontoga, millega algne ost tehti.</string>
<string name="dialog_early_access_desc">Te\'toodate Episteme Pro meie varase juurdepääsu perioodil erisoodushinnaga! See on piiratud aja pakkumine.</string>
<string name="dialog_sign_in_required_desc">Logige sisse oma Google konto ostmiseks Episteme Pro ja avage kõik esmaklassilised funktsioonid.</string>
<string name="dialog_sign_in_required_desc">Logi Googlei kontoga sisse, et osta Episteme Pro ja avada kõik premium-funktsioonid.</string>
<string name="action_not_now">Mitte praegu</string>
<string name="action_got_it">Selge!</string>
<string name="custom_fonts">Kohandatud fondid</string>
@ -218,40 +218,40 @@
<string name="google_fonts_no_matches">No fonts found matching \'%1$s\'</string>
<string name="content_desc_already_downloaded">Juba alla laaditud</string>
<string name="no_custom_fonts">Kohandatud fonte pole</string>
<string name="import_fonts_desc">Importige TTF- või OTF-faile, et neid oma raamatutes kasutada.</string>
<string name="import_fonts_desc">Impordi TTF- või OTF-faile, et neid oma raamatutes kasutada.</string>
<string name="font_preview_error">Eelvaade pole saadaval (kehtetu fondifail)</string>
<string name="dialog_delete_font">Kas kustutada font?</string>
<string name="dialog_delete_font_desc">Kas soovite kindlasti kustutada \'%1$s\'? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist teie seadmetest.</string>
<string name="dialog_delete_font_desc">Kas kustutada &quot;%1$s&quot;? Kui sünkroonimine on sisse lülitatud, eemaldatakse see kõigist seadmetest.</string>
<string name="dialog_delete_fonts">Kas kustutada fondid?</string>
<string name="dialog_delete_fonts_desc">Kas soovite kindlasti kustutada %1$d valitud fonte? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist teie seadmetest.</string>
<string name="dialog_delete_fonts_desc">Kas kustutada %1$d valitud fonti? Kui sünkroonimine on sisse lülitatud, eemaldatakse need kõigist seadmetest.</string>
<string name="get_in_touch">Võtke ühendust</string>
<string name="feedback_desc">Kas leidsite vea, teil on funktsioonitaotlus või soovite lihtsalt tere öelda? Andke meile teada GitHubis või saatke meile e-kiri.</string>
<string name="feedback_desc">Leidsid vea, sul on funktsioonisoov või tahad lihtsalt tere öelda? Anna GitHubis teada või saada meile e-kiri.</string>
<string name="github_issues">GitHubi probleemid</string>
<string name="github_issues_desc">Teatage vigadest, taotlege funktsioone ja jälgige arenduse edenemist.</string>
<string name="email_support">Meili tugi</string>
<string name="email_support_desc">Muude päringute korral võtke meiega otse e-posti teel ühendust.</string>
<string name="support_project_title">Toetage projekti</string>
<string name="support_project_title">Toeta projekti</string>
<string name="support_project_heading">Aidake hoida Episteme liigub</string>
<string name="support_project_desc">Teie tugi aitab mul hoida ja täiustada Episteme kõigile!!!</string>
<string name="support_project_desc">Sinu tugi aitab Epistemet kõigi jaoks hoida ja täiustada.</string>
<string name="support_github_sponsor">Sponsor GitHubis</string>
<string name="support_github_sponsor_desc">Toetage arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause.</string>
<string name="support_github_sponsor_desc">Toeta arendust otse GitHubi sponsorite kaudu. Tänutäheks saate projekti repos README hüüdlause.</string>
<string name="support_patreon">Liituge Patreoniga</string>
<string name="support_patreon_desc">Tänutäheks rakenduse toetamise eest saavad Patreoni toetajad lisasisu ja -hüvesid: pilkupüüre sellest, millega ma töötan, varasemaid ekraanipilte ja värskendusi, hääli, mis aitavad kujundada, kuidas uued funktsioonid peaksid välja nägema ja töötama, ning README-hüüde projekti repos.</string>
<string name="dialog_unlock_pro">Avage Episteme Pro</string>
<string name="dialog_unlock_pro_desc">Seadmetevaheline sünkroonimine on Pro funktsioon. Avage kõik professionaalsed funktsioonid ühe ühekordse ostuga.</string>
<string name="dialog_unlock_pro">Ava Episteme Pro</string>
<string name="dialog_unlock_pro_desc">Seadmetevaheline sünkroonimine on Pro-funktsioon. Ava kõik Pro-funktsioonid ühe ühekordse ostuga.</string>
<string name="action_upgrade">Uuendage</string>
<string name="dialog_confirm_sign_out">Kinnitage väljalogimine</string>
<string name="dialog_confirm_sign_out_desc">Kas olete kindel, et soovite välja logida?</string>
<string name="dialog_confirm_sign_out_desc">Kas logida välja?</string>
<string name="device_limit_reached">Seadme limiit on saavutatud</string>
<string name="device_limit_reached_desc">Kasutamiseks Episteme Pro selles seadmes eemaldage üks oma olemasolevatest registreeritud seadmetest.</string>
<string name="device_limit_reached_desc">Episteme Pro kasutamiseks selles seadmes eemalda üks olemasolevatest registreeritud seadmetest.</string>
<string name="last_seen">Viimati nähtud: %1$s</string>
<string name="dialog_destructive_action">Kinnitage hävitav tegevus</string>
<string name="dialog_destructive_action_desc">See kustutab jäädavalt kõik teie raamatud ja lugemise edenemine sellest seadmest JA teie seadmest Google Drive konto. Seda toimingut ei saa tagasi võtta. Oled sa kindel?</string>
<string name="dialog_destructive_action_desc">See kustutab jäädavalt kõik raamatud ja lugemisjärje sellest seadmest ning Google Drivei kontolt. Seda toimingut ei saa tagasi võtta. Kas jätkata?</string>
<string name="dialog_clear_book_cache">Tühjenda raamatu vahemälu</string>
<string name="dialog_clear_book_cache_desc">See kustutab kõik töödeldud lehed lehekülgede muutmise režiimis. See aitab lahendada paigutusprobleeme, kuid järgmisel korral tuleb raamatute avamisel uuesti töödelda.</string>
<string name="action_confirm_clear">Kinnita ja kustuta</string>
<string name="dialog_clear_reflow_cache">Tühjendage reflow vahemälu</string>
<string name="dialog_clear_reflow_cache_desc">See kustutab kõik loodud \'Tekstivaade\' PDF-ide versioonid ja tühjendage nendega seotud pildid/HTML-i vahemälu. Teie algsed PDF-id jäävad puutumata.</string>
<string name="dialog_clear_reflow_cache">Tühjenda reflow-vahemälu</string>
<string name="dialog_clear_reflow_cache_desc">See kustutab kõik loodud PDF-ide tekstivaate versioonid ja tühjendab nendega seotud piltide/HTML-i vahemälu. Algsed PDF-id jäävad puutumata.</string>
<string name="tooltip_back">Tagasi</string>
<string name="tooltip_dictionary">Sõnastik</string>
<string name="tooltip_more_options">Rohkem valikuid</string>
@ -267,7 +267,7 @@
<string name="tooltip_dark_mode_on">Luba tume režiim</string>
<string name="tooltip_dark_mode_off">Keela tume režiim</string>
<string name="tooltip_lock_pan">Lukusta panoraam</string>
<string name="tooltip_unlock_pan">Avage panoraam</string>
<string name="tooltip_unlock_pan">Ava panoraamimine</string>
<string name="tooltip_fullscreen">Täisekraan</string>
<string name="tooltip_highlights">Kuva esiletõstmised</string>
<string name="tooltip_highlights_off">Peida esiletõstmised</string>
@ -280,37 +280,37 @@
<string name="tooltip_prev_result">Eelmine tulemus</string>
<string name="tooltip_next_result">Järgmine tulemus</string>
<string name="tooltip_back_desc">Väljuge lugejast ja naaske avakuvale</string>
<string name="tooltip_dictionary_desc">Valige sõnade otsimiseks eelistatud rakendus</string>
<string name="tooltip_dictionary_desc">Vali sõnade otsimiseks eelistatud rakendus</string>
<string name="tooltip_more_options_desc">Juurdepääs lugemisrežiimile, järjehoidjatele ja täpsematele seadetele</string>
<string name="tooltip_slider_desc">Lohistage, et hüpata kiiresti dokumendi mis tahes lehele</string>
<string name="tooltip_toc_desc">Sirvige peatükke ja navigeerige mis tahes jaotisesse</string>
<string name="tooltip_format_desc">Reguleerige fonti, suurust, rea kõrgust, joondamist ja kohandatud fonte</string>
<string name="tooltip_search_desc">Otsige sellest raamatust üles mis tahes sõna või fraas</string>
<string name="tooltip_ai_desc">Tehke praegusest peatükist või leheküljest kokkuvõte, kasutades AI</string>
<string name="tooltip_tts_start_desc">Lugege raamatut ette oma seadme\'s häälemootori abil</string>
<string name="tooltip_tts_start_desc">Loe raamatut ette oma seadme\'s häälemootori abil</string>
<string name="tooltip_tts_stop_desc">Peatage praegune ettelugemise seanss</string>
<string name="tooltip_tts_pause_desc">Peatage praegune etteloetud taasesitus</string>
<string name="tooltip_tts_resume_desc">Jätkake peatatud ettelugemisega taasesitust</string>
<string name="tooltip_tts_resume_desc">Jätka peatatud ettelugemist</string>
<string name="tooltip_dark_mode_on_desc">Inverteerida PDF värvid tumeda režiimi jaoks</string>
<string name="tooltip_dark_mode_off_desc">Keela tume režiim ja taasta originaal PDF värvid</string>
<string name="tooltip_lock_pan_desc">Lukustage lehel horisontaalne panoraam</string>
<string name="tooltip_unlock_pan_desc">Avage panoraam, et uuesti lubada suumimiseks ja lohistamiseks kokkusurutud liigutused</string>
<string name="tooltip_lock_pan_desc">Lukusta lehel horisontaalne panoraamimine</string>
<string name="tooltip_unlock_pan_desc">Ava panoraamimine, et lubada uuesti suumimis- ja lohistusliigutused</string>
<string name="tooltip_fullscreen_desc">Peitke kõik kasutajaliidese juhtnupud, et näha kaasahaaravat ja häireteta lugemisvaadet</string>
<string name="tooltip_highlights_desc">Märkige visuaalselt valitud tekstipiirkonnad praegusel lehel</string>
<string name="tooltip_highlights_off_desc">Eemaldage lehelt valitav tekstiülekate</string>
<string name="tooltip_highlights_off_desc">Eemalda lehelt valitav tekstiülekate</string>
<string name="tooltip_edit_mode_desc">Lisage tinti või tekstimärkusi</string>
<string name="tooltip_edit_mode_exit_desc">Lõpetage redigeerimine ja naaske tavalisse lugemisvaatesse</string>
<string name="tooltip_close_search_desc">Väljuge otsingust ja minge tagasi lugeja juurde</string>
<string name="tooltip_clear_search_desc">Kustutage praegune otsingupäring ja alustage otsast peale</string>
<string name="tooltip_show_results_desc">Laiendage paneeli, et näha kõiki otsingu vasteid</string>
<string name="tooltip_hide_results_desc">Ahendage otsingutulemuste paneel</string>
<string name="tooltip_show_results_desc">Laienda paneeli, et näha kõiki otsinguvastuseid</string>
<string name="tooltip_hide_results_desc">Ahenda otsingutulemuste paneel</string>
<string name="tooltip_prev_result_desc">Hüppa dokumendis eelmisele otsingu vastele</string>
<string name="tooltip_next_result_desc">Hüppa dokumendis järgmise otsingu vaste juurde</string>
<string name="action_sign_in">Logi sisse</string>
<string name="action_select_folder">Valige kaust</string>
<string name="action_select">Valige</string>
<string name="action_select_folder">Vali kaust</string>
<string name="action_select">Vali</string>
<string name="legal_footer_combined">Privaatsuspoliitika • Kasutustingimused • Litsentsid</string>
<string name="error_folder_selection_unsupported">Teie seade\' ei toeta kaustade valikut. Saate endiselt faile ükshaaval importida.</string>
<string name="error_folder_selection_unsupported">Sinu seade ei toeta kaustade valimist. Faile saab endiselt ükshaaval importida.</string>
<string name="error_no_file_manager">Failihaldurit ei leitud. Installige failihalduri rakendus.</string>
<string name="banner_downloaded">Allalaaditud %1$s</string>
<string name="filter_facet">%1$s: %2$s</string>
@ -323,7 +323,7 @@
<string name="error_purchase_general">Ostmisel ilmnes viga.</string>
<string name="banner_upgrade_success">Uuendamine õnnestus! Tere tulemast Pro-sse.</string>
<string name="error_purchase_verification">Ostu kinnitamine ebaõnnestus. Kui teilt võeti tasu, võtke ühendust klienditoega.</string>
<string name="banner_device_removed">See seade eemaldati teie kontolt.</string>
<string name="banner_device_removed">See seade eemaldati sinu kontolt.</string>
<string name="error_verify_device">Seda seadet ei saanud kinnitada. Palun kontrollige oma ühendust.</string>
<string name="error_update_devices">Seadmete värskendamine ebaõnnestus. Palun proovi uuesti.</string>
<string name="banner_saving_pdf">Säästmine PDF…</string>
@ -332,9 +332,15 @@
<string name="error_saving_pdf">Viga salvestamisel PDF: %1$s</string>
<string name="banner_saving_original_pdf">Originaali salvestamine PDF…</string>
<string name="banner_original_pdf_saved">Originaal PDF edukalt salvestatud.</string>
<string name="banner_saving_original_file">Algse faili salvestamine…</string>
<string name="banner_original_file_saved">Algne fail salvestati.</string>
<string name="error_saving_file">Faili salvestamisel tekkis viga: %1$s</string>
<string name="share_subject">Jagamine: %1$s</string>
<string name="share_chooser_title">Jaga PDF</string>
<string name="share_file_chooser_title">Jaga faili</string>
<string name="error_share_failed">Jagamine ebaõnnestus: %1$s</string>
<string name="error_copy_to_clipboard">Lõikelauale kopeerimine nurjus</string>
<string name="error_print_password_protected">Parooliga kaitstud PDF-faile ei saa printida</string>
<string name="error_folder_limit_reached">Limiit saavutatud: maksimaalne %1$d kaustad lubatud.</string>
<string name="error_folder_already_synced">See kaust on juba sünkroonitud.</string>
<string name="banner_folder_added">Lisatud kaust: %1$s</string>
@ -360,7 +366,7 @@
<string name="error_sign_in_failed">Sisselogimine ebaõnnestus. Palun proovi uuesti.</string>
<string name="error_no_google_account">Ei leitud Google konto. See võib juhtuda värske installi korral, proovige mõne aja pärast uuesti.</string>
<string name="error_sign_in_internet">Sisselogimisel ilmnes viga. Kontrollige oma Interneti-ühendust.</string>
<string name="error_sign_in_device_management">Seadmehalduse testimiseks logige sisse.</string>
<string name="error_sign_in_device_management">Seadmehalduse testimiseks logi sisse.</string>
<string name="error_sync_pro_feature">Sünkroonimine on Episteme Pro funktsiooni.</string>
<string name="error_not_signed_in_sync">Pole sisse logitud, ei saa sünkroonida.</string>
<string name="banner_cloud_sync_checking">Pilvesünkroonimine: värskenduste otsimine…</string>
@ -386,7 +392,7 @@
<string name="search_no_results_simple">Tulemusi ei leitud.</string>
<string name="generating_summary">Kokkuvõtte genereerimine…</string>
<string name="action_stop">Peatus</string>
<string name="action_read_aloud">Lugege ette</string>
<string name="action_read_aloud">Loe ette</string>
<string name="action_copy">Kopeeri</string>
<string name="action_copy_thread">Kopeeri lõim</string>
<string name="no_summary_generated">Kokkuvõtet ei saanud luua.</string>
@ -404,7 +410,7 @@
<string name="tts_device_voice_settings">Seadme hääleseaded</string>
<string name="content_desc_close_settings">Sulgege seaded</string>
<string name="tts_system_default">Süsteemi vaikeseade</string>
<string name="tts_system_default_desc">Vastab teie Android süsteemi seaded</string>
<string name="tts_system_default_desc">Vastab sinu Android süsteemi seaded</string>
<string name="content_desc_selected">Valitud</string>
<string name="tts_loading_voices">Häälte laadimine…</string>
<string name="tts_no_voices">Selles seadmes pole hääli saadaval.</string>
@ -450,7 +456,7 @@
<string name="dict_external_description">Kasutab valitud rakendust sõnastikust otsimiseks.</string>
<string name="dict_fallback_app">Varurakendus</string>
<string name="dict_dictionary_app">Sõnastiku rakendus</string>
<string name="dict_select_app">Valige rakendus</string>
<string name="dict_select_app">Vali rakendus</string>
<string name="dict_translate">Tõlgi</string>
<string name="dict_translate_description">Rakendus, mida kasutatakse valitud teksti tõlkimiseks.</string>
<string name="dict_search_app">Otsi rakendust</string>
@ -466,16 +472,16 @@
<string name="ai_generating_recap">Kokkuvõtte genereerimine…</string>
<string name="ai_chapter_summary">Peatüki kokkuvõte</string>
<string name="ai_story_recap_beta">Loo kokkuvõte (beeta)</string>
<string name="ai_unlock_summarization">Avage peatüki kokkuvõte</string>
<string name="ai_unlock_summarization">Ava peatüki kokkuvõte</string>
<string name="ai_unlock_summarization_desc">Saate mis tahes peatüki lühikokkuvõtteid kasutades Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string>
<string name="action_learn_more">Lisateave</string>
<string name="ai_unlock_smart_dict">Avage nutikas sõnaraamat</string>
<string name="ai_unlock_smart_dict">Ava nutikas sõnastik</string>
<string name="ai_unlock_smart_dict_desc">Tervete fraaside ja lõikude määratlemine kuni 2000 tähemärgini on Pro funktsioon. Täiendage, et saada mis tahes valitud teksti jaoks kohesed määratlused.</string>
<string name="content_desc_bookmark_icon">Järjehoidja</string>
<string name="content_desc_selected_slot">Valitud pesa</string>
<string name="dialog_customize_palette">Kohandage palett</string>
<string name="palette_tap_slot_to_edit">Puudutage muutmiseks pesa:</string>
<string name="palette_select_color_for_slot">Valige pesa värv:</string>
<string name="palette_select_color_for_slot">Vali pesa värv:</string>
<string name="chapter_empty">See peatükk on tühi.</string>
<string name="chapter_not_found">Peatükki ei leitud</string>
<string name="error_loading_chapter">Viga peatüki laadimisel</string>
@ -496,7 +502,7 @@
<string name="menu_volume_button_scrolling">Helitugevuse nupu kerimine</string>
<string name="menu_volume_button_page_turn">Helitugevuse nupp Lehekülje pööramine</string>
<string name="menu_realistic_page_turns">Realistlikud leheküljepöörded</string>
<string name="menu_keep_screen_on">Hoidke ekraan sees</string>
<string name="menu_keep_screen_on">Hoia ekraan sees</string>
<string name="menu_visual_options">Visuaalsed valikud</string>
<string name="menu_screen_orientation">Ekraani suund</string>
<string name="menu_change_reading_mode">Muutke lugemisrežiimi</string>
@ -526,7 +532,7 @@
<string name="content_desc_start_playback">Mängi</string>
<string name="auto_scroll_local_speed">Kohalik kiirus</string>
<string name="auto_scroll_global_speed">Globaalne kiirus</string>
<string name="content_desc_select_mode">Valige Režiim</string>
<string name="content_desc_select_mode">Vali režiim</string>
<string name="auto_scroll_applies_all_files">Kehtib kõikidele failidele</string>
<string name="auto_scroll_saved_for_file">Salvestatud ainult selle faili jaoks</string>
<string name="content_desc_disable_musician_mode">Keela muusiku režiim</string>
@ -550,25 +556,25 @@
<string name="action_locate">Otsige üles</string>
<string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string>
<string name="no_images_found">Pilte ei leitud.</string>
<string name="content_desc_download_image">Laadige pilt alla</string>
<string name="content_desc_download_image">Laadi pilt alla</string>
<string name="content_desc_more_options_bookmark">Rohkem valikuid järjehoidja jaoks</string>
<string name="dialog_rename_bookmark">Nimeta järjehoidja ümber</string>
<string name="label_new_name">Uus nimi</string>
<string name="label_new_title">Uus pealkiri</string>
<string name="dialog_delete_bookmark">Kas kustutada järjehoidja?</string>
<string name="dialog_delete_bookmark_desc">Kas olete kindel, et soovite selle järjehoidja jäädavalt kustutada?</string>
<string name="dialog_delete_bookmark_desc">Kas oled kindel, et soovid selle järjehoidja jäädavalt kustutada?</string>
<string name="no_highlights_yet">Esiletõsteid veel pole.</string>
<string name="unknown_chapter">Tundmatu peatükk</string>
<string name="content_desc_options">Valikud</string>
<string name="dialog_delete_highlight">Kas kustutada esiletõst?</string>
<string name="dialog_delete_highlight_desc">Kas olete kindel, et soovite selle esiletõstmise jäädavalt kustutada?</string>
<string name="dialog_delete_highlight_desc">Kas kustutada see esiletõst jäädavalt?</string>
<string name="saved_image_message">Salvestatud %1$s</string>
<string name="error_save_image">Pilti ei saanud salvestada.</string>
<string name="banner_original_pdf_not_found">Originaal PDF ei leitud.</string>
<string name="error_book_content_not_found">Viga: raamatu sisu ei leitud. Tee: %1$s</string>
<string name="toast_select_dictionary_first">Valige esmalt sõnastikurakendus.</string>
<string name="toast_select_translate_first">Valige esmalt tõlkerakendus.</string>
<string name="toast_select_search_first">Valige esmalt otsingurakendus.</string>
<string name="toast_select_dictionary_first">Vali esmalt sõnastikurakendus.</string>
<string name="toast_select_translate_first">Vali esmalt tõlkerakendus.</string>
<string name="toast_select_search_first">Vali esmalt otsingurakendus.</string>
<string name="no_chapters_available">Selle raamatu jaoks pole peatükke saadaval.</string>
<string name="navigating_to_position">Navigeerimine asukohta…</string>
<string name="dialog_permission_required">Nõutav luba</string>
@ -578,7 +584,7 @@
<string name="dialog_justified_text_limitation_desc">Põhjendatud joonduse kasutamine leheküljelises režiimis võib küljenduse piirangute tõttu muuta teksti valiku ja esiletõstmised ebatäpseks.</string>
<string name="action_i_understand">ma saan aru</string>
<string name="navigating_to_chapter">Peatükki navigeerimine…</string>
<string name="toast_select_offline_dict_first">Valige esmalt võrguühenduseta sõnastik.</string>
<string name="toast_select_offline_dict_first">Vali esmalt võrguühenduseta sõnastik.</string>
<string name="banner_book_not_paginated">Raamat pole veel lehekülgedega varustatud.</string>
<string name="banner_wait_for_load">Oodake, kuni raamat on täielikult laaditud.</string>
<string name="release_for_previous_chapter">Eelmise peatüki väljalase</string>
@ -594,7 +600,7 @@
<string name="action_reset">Lähtesta</string>
<string name="label_size">Suurus</string>
<string name="label_spacing">Vahekaugus</string>
<string name="select_font">Valige Font</string>
<string name="select_font">Vali Font</string>
<string name="tab_presets">Eelseaded</string>
<string name="tab_imported">Imporditud</string>
<string name="button_import_from_files">Import failidest</string>
@ -606,20 +612,20 @@
<string name="visual_options_pdf_spread_two">Kaks lehte</string>
<string name="visual_options_pdf_first_page_alone">Esimene leht üksi</string>
<string name="visual_options_pdf_first_page_alone_desc">Alustab esikülje laialivalgumist pärast kaanelehte.</string>
<string name="visual_options_remove_page_gap">Eemaldage lehtede vahe</string>
<string name="visual_options_remove_page_gap">Eemalda lehtede vahe</string>
<string name="visual_options_remove_page_gap_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
<string name="visual_options_hide_page_number_overlay">Peida lehenumbri ülekate</string>
<string name="visual_options_hide_page_number_overlay_desc">Eemaldab igalt lehelt väikese lehekülgede arvu sildi.</string>
<string name="visual_options_system_ui">Süsteemi kasutajaliides (oleku- ja navigeerimisribad)</string>
<string name="visual_options_system_ui_desc">Kontrollige seadme\'-süsteemi ribade nähtavust.</string>
<string name="visual_options_screen_orientation">Ekraani suund</string>
<string name="visual_options_screen_orientation_desc">Valige, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda.</string>
<string name="visual_options_screen_orientation_desc">Vali, kas lugeja järgib süsteemi orientatsiooni või eelistab vertikaalset või horisontaalset, kui Android lubab seda.</string>
<string name="visual_options_progress_bar">Edenemisriba</string>
<string name="visual_options_progress_bar_desc">Lugemise edenemise ja peatüki indikaator lugemisekraanil.</string>
<string name="visual_options_progress_bar_position">positsioon</string>
<string name="visual_options_seamless_chapter">Peatükkide sujuv üleminek</string>
<string name="visual_options_seamless_chapter_desc">Laadige lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita.</string>
<string name="visual_options_edge_padding">Eemaldage serva polsterdus</string>
<string name="visual_options_seamless_chapter_desc">Laadi lõpust mööda kerides kohe järgmine/eelmine peatükk, ilma tõmmake värskendamiseks animatsioonita.</string>
<string name="visual_options_edge_padding">Eemalda servapolsterdus</string>
<string name="visual_options_edge_padding_desc">Eemaldab horisontaalse vahe vasakust ja paremast servast.</string>
<string name="reader_brightness_title">Heledus</string>
<string name="reader_brightness_system">Kasutage süsteemi heledust</string>
@ -636,10 +642,10 @@
<string name="about_version_name">Versioon %1$s</string>
<string name="about_build_code">Ehitamine %1$s</string>
<string name="about_github_desc">Sirvige lähtekoodi, tärniga, kahvliga ja teatage probleemidest.</string>
<string name="about_privacy_desc">Kuidas me teie andmeid käsitleme.</string>
<string name="about_privacy_desc">Kuidas me sinu andmeid käsitleme.</string>
<string name="about_terms_desc">Kasutustingimused.</string>
<string name="about_licenses_desc">Kasutatud avatud lähtekoodiga teegid.</string>
<string name="banner_importing_multiple">Importimine %1$d raamatud… Need ilmuvad peagi teie kogusse.</string>
<string name="banner_importing_multiple">Importimine %1$d raamatud… Need ilmuvad peagi sinu kogusse.</string>
<string name="banner_shelf_created">Loodi riiul &quot;%1$s&quot;.</string>
<string name="banner_smart_shelf_created">Loodi nutikas riiul &quot;%1$s&quot;.</string>
<string name="banner_shelf_renamed">Riiul nimetati ümber &quot;%1$s&quot;.</string>
@ -678,7 +684,7 @@
<string name="tts_voice_adjustments">Hääle reguleerimine</string>
<string name="tts_speed_label">Kiirus (%1$sx)</string>
<string name="tts_pitch_label">Kõrgus (%1$sx)</string>
<string name="tts_sample_text">Nii kõlavad teie praegused hääleseaded.</string>
<string name="tts_sample_text">Nii kõlavad sinu praegused hääleseaded.</string>
<string name="tts_pause_book">Peata raamat</string>
<string name="tts_resume_book">Jätkamise raamat</string>
<string name="tts_system_settings">Süsteemi hääle/mootori sätted</string>
@ -688,7 +694,7 @@
<string name="auto_scroll_desc_local">Salvestatud ainult selle faili jaoks</string>
<string name="action_scroll_to_top">Kerige üles</string>
<string name="title_customize_toolbar">Kohandage tööriistariba</string>
<string name="desc_customize_toolbar">Valige tööriistad, mida soovite nähtavana hoida. Tööriista märke tühistamine peidab selle kasutajaliidese eest, et anda teile tähelepanu kõrvalejuhtimiseta lugemisruumi.</string>
<string name="desc_customize_toolbar">Vali tööriistad, mida nähtavana hoida. Tööriista märke eemaldamine peidab selle kasutajaliidesest, et lugemisruum oleks häirimatu.</string>
<string name="tab_annotations">Märkused</string>
<string name="filter_all">Kõik</string>
<string name="filter_with_notes">Märkmetega</string>
@ -706,8 +712,8 @@
<string name="content_desc_undo">Võta tagasi</string>
<string name="content_desc_redo">Tee uuesti</string>
<string name="content_desc_show_dock">Näita dokki</string>
<string name="content_desc_select_font_family">Valige Fontide perekond</string>
<string name="content_desc_select_font_size">Valige Fondi suurus</string>
<string name="content_desc_select_font_family">Vali fondipere</string>
<string name="content_desc_select_font_size">Vali Fondi suurus</string>
<string name="content_desc_font_background">Fondi taust</string>
<string name="content_desc_bold">Paks</string>
<string name="content_desc_italic">Kursiiv</string>
@ -731,7 +737,7 @@
<string name="menu_insert_blank_page">Sisesta tühi leht</string>
<string name="menu_delete_page">Kustuta leht</string>
<string name="generating_reflow_progress">Tekib… %1$d%%</string>
<string name="action_open_text_view">Avage tekstivaade</string>
<string name="action_open_text_view">Ava tekstivaade</string>
<string name="action_generate_text_view">Loo tekstivaade</string>
<string name="action_share">Jaga</string>
<string name="action_save_copy_to_device">Salvesta koopia seadmesse</string>
@ -743,11 +749,11 @@
<string name="msg_search_pages_count">%1$d+ Lehekülgi</string>
<string name="action_summarize_page">Lehe kokkuvõte (lehekülg %1$d)</string>
<string name="msg_downloading_language_pack">Allalaadimine %1$s keelepakett…</string>
<string name="title_select_ocr_language">Valige OCR Keel</string>
<string name="desc_select_ocr_language">Paremate tekstituvastustulemuste saamiseks valige selle dokumendi esmane keel/skript.</string>
<string name="title_select_ocr_language">Vali OCR Keel</string>
<string name="desc_select_ocr_language">Paremate tekstituvastustulemuste saamiseks vali selle dokumendi peamine keel/kiri.</string>
<string name="desc_ocr_language_change_later">Saate seda hiljem muuta jaotises Rohkem valikuid &gt; OCR Keel.</string>
<string name="title_reindex_document">Kas dokument uuesti indekseerida?</string>
<string name="desc_reindex_document_warning">You are changing the OCR script to %1$s.\n\nTo ensure search accuracy, we need to clear the existing index and re-scan pages that require OCR using this new language.\n\nThis will happen in the background.</string>
<string name="desc_reindex_document_warning">Muudad OCR-i kirja väärtuseks %1$s.\n\nOtsingu täpsuse tagamiseks peame olemasoleva indeksi tühjendama ja OCR-i vajavad lehed selle uue keelega uuesti skannima.\n\nSee toimub taustal.</string>
<string name="action_reindex">Indekseeri uuesti</string>
<string name="title_password_protected">Parooliga kaitstud</string>
<string name="desc_password_protected">See dokument on krüpteeritud. Selle vaatamiseks sisestage parool.</string>
@ -758,13 +764,13 @@
<string name="desc_external_link_warning">You are about to navigate to:\n%1$s</string>
<string name="action_visit">Külastage</string>
<string name="title_save_to_device">Salvesta seadmesse</string>
<string name="desc_choose_format_save">Valige salvestamiseks vorming:</string>
<string name="desc_choose_format_save">Vali salvestamiseks vorming:</string>
<string name="action_with_annotations">Koos märkustega</string>
<string name="action_original">Originaal</string>
<string name="desc_choose_format_share">Valige jagamiseks vorming:</string>
<string name="desc_choose_format_share">Vali jagamiseks vorming:</string>
<string name="msg_preparing_pdf">Ettevalmistus PDF…</string>
<string name="title_add_pdf_to_tab">Lisa PDF vahekaardile</string>
<string name="msg_no_other_pdfs_found">Teisi PDF-e teie teegist ei leitud.</string>
<string name="msg_no_other_pdfs_found">Teisi PDF-e sinu teegist ei leitud.</string>
<string name="msg_pdf_empty_or_error">PDF on tühi või seda ei saa kuvada.</string>
<string name="msg_page_added_at">Leht lisatud aadressil %1$d</string>
<string name="msg_page_deleted">Leht kustutatud</string>
@ -792,23 +798,24 @@
<string name="dialog_strict_file_filter_title">Luba range failifilter</string>
<string name="dialog_strict_file_filter_desc">If you enable this, some supported file types like AZW3, CB7, and FB2 might not show up depending on your file manager.\n\nAre you sure you want to enable this filter?</string>
<string name="language_system_default">Süsteemi vaikeseade</string>
<string name="language_english">inglise keel</string>
<string name="language_english_default">inglise keel (vaikimisi)</string>
<string name="language_english">English (inglise)</string>
<string name="language_english_default">English (vaikimisi)</string>
<string name="language_arabic">العربية (araabia)</string>
<string name="language_german">saksa (saksa)</string>
<string name="language_german">Deutsch (saksa)</string>
<string name="language_turkish">türkçe (türgi)</string>
<string name="language_french">Français (prantsuse)</string>
<string name="language_russian">Русский (vene)</string>
<string name="language_belarusian">Беларуская (valgevene keel)</string>
<string name="language_spanish">español (hispaania)</string>
<string name="language_portuguese_brazilian">portugali keel (Brasiilia)</string>
<string name="language_italian">itaalia keel (itaalia)</string>
<string name="language_portuguese_brazilian">Português (Brasiilia)</string>
<string name="language_italian">Italiano (itaalia)</string>
<string name="language_polish">polski (poola)</string>
<string name="language_vietnamese">Tiếng Việt (vietnami)</string>
<string name="language_japanese">日本語 (jaapani keel)</string>
<string name="language_korean">한국어 (korea)</string>
<string name="language_hindi">हिन्दी (hindi)</string>
<string name="language_chinese_simplified">简体中文 (hiina, lihtsustatud)</string>
<string name="language_estonian">Eesti</string>
<string name="app_theme_title">Rakenduse teema</string>
<string name="app_theme_appearance">Välimus</string>
<string name="app_theme_contrast">Kontrast</string>
@ -828,7 +835,7 @@
<string name="content_desc_app_theme">Rakenduse teema</string>
<string name="content_desc_app_icon">Rakenduse ikoon</string>
<string name="content_desc_device">Seade</string>
<string name="content_desc_open_drawer">Avage sahtel</string>
<string name="content_desc_open_drawer">Ava sahtel</string>
<string name="content_desc_profile_picture">Profiilipilt</string>
<string name="content_desc_profile">Profiil</string>
<string name="content_desc_pro_feature">Pro funktsioon</string>
@ -874,7 +881,7 @@
<string name="tts_tab_cloud_voices">Pilve hääled</string>
<string name="tts_tab_device_voices">Seadme hääled</string>
<string name="tts_tab_cloud_cache">Pilve vahemälu</string>
<string name="tts_select_cloud_voice">Valige Kvaliteetne pilvehääl</string>
<string name="tts_select_cloud_voice">Vali Kvaliteetne pilvehääl</string>
<string name="tts_clear_samples">Puhasta proovid</string>
<string name="tts_system_default_voice">Süsteemi vaikehääl</string>
<string name="tts_uses_device_settings">Kasutab seadme sätteid</string>
@ -913,11 +920,11 @@
<string name="legal_by_purchasing">Ostes,</string>
<string name="dialog_out_of_credits_title">Krediidid otsas</string>
<string name="dialog_out_of_credits_desc">Teil ei ole piisavalt krediiti\' Hangi Episteme Pro 10 tasuta kokkuvõtet päevas või lisage krediiti, et kasutada kokkuvõtteid, Cloud TTS ja loo kokkuvõte.</string>
<string name="action_get_pro_or_add_credits">Hankige Pro / lisage krediiti</string>
<string name="dialog_unlock_page_summarization">Avage lehe kokkuvõte</string>
<string name="dialog_unlock_page_summarization_desc">Hankige täpseid kokkuvõtteid mis tahes lehekülje kohta, millel on Episteme Pro. Selle funktsiooni kasutamise alustamiseks uuendage.</string>
<string name="dialog_download_bubble_zoom_model">Laadige alla Bubble Zoom mudel</string>
<string name="dialog_download_bubble_zoom_model_desc">Funktsiooni Bubble Zoom kasutamiseks kasutage AI mudel tuleb alla laadida (~134 MB). Kas soovite selle kohe alla laadida?</string>
<string name="action_get_pro_or_add_credits">Hangi Pro / lisa krediiti</string>
<string name="dialog_unlock_page_summarization">Ava lehe kokkuvõte</string>
<string name="dialog_unlock_page_summarization_desc">Hangi Episteme Proga täpseid kokkuvõtteid mis tahes lehekülje kohta. Uuenda, et seda funktsiooni kasutada.</string>
<string name="dialog_download_bubble_zoom_model">Laadi alla Bubble Zoom mudel</string>
<string name="dialog_download_bubble_zoom_model_desc">Bubble Zoomi kasutamiseks tuleb alla laadida AI-mudel (~134 MB). Kas laadida see kohe alla?</string>
<string name="action_translate">Tõlgi</string>
<string name="pdf_back_to_page_short">Tagasi lk %1$d</string>
<string name="pdf_page_short">Lehekülg %1$d</string>
@ -939,7 +946,7 @@
<string name="content_desc_reset_zoom">Lähtestage suum</string>
<string name="content_desc_generate_demo_annotations">Loo demomärkusi</string>
<string name="tooltip_demo_annotations">Demo annotatsioonid</string>
<string name="content_desc_open_pen_playground">Avage pliiatsi mänguväljak</string>
<string name="content_desc_open_pen_playground">Ava pliiatsi mänguväljak</string>
<string name="content_desc_new_tab">Uus vaheleht</string>
<string name="content_desc_highlight_all_text">Tõstke esile kogu tekst</string>
<string name="content_desc_toggle_editing_mode">Lülitage redigeerimisrežiim sisse</string>
@ -949,7 +956,7 @@
<string name="ocr_language_devanagari">hindi, marati, sanskriti + inglise keel</string>
<string name="ocr_language_chinese">hiina + inglise keel</string>
<string name="ocr_language_japanese">jaapani + inglise keel</string>
<string name="ocr_language_korean">Korea + inglise keel</string>
<string name="ocr_language_korean">korea + inglise keel</string>
<string name="msg_page_unavailable">Leht pole saadaval</string>
<string name="default_document_title">Dokument</string>
<string name="generated_author">Loodud</string>
@ -968,7 +975,7 @@
<string name="content_desc_play_pause">Esita/Paus</string>
<string name="content_desc_reset_speed">Lähtestage kiirus</string>
<string name="content_desc_reset_pitch">Lähtesta helikõrgus</string>
<string name="dialog_select_color">Valige Värv</string>
<string name="dialog_select_color">Vali Värv</string>
<string name="msg_book_no_content">Sellel raamatul pole kuvatavat sisu.</string>
<string name="clip_label_copied_link">Kopeeritud link</string>
<string name="clip_label_copied_text">Kopeeritud tekst</string>
@ -1000,8 +1007,8 @@
<string name="pdf_error_document_not_loaded">Dokumenti ei laaditud.</string>
<string name="ai_error_offline_oss">AI funktsioonid pole võrguühenduseta saadaval OSS ehitada.</string>
<string name="ai_error_blocked_safety">Turvakaalutlustel blokeeritud.</string>
<string name="ai_error_choose_model">Valige mudel %1$s aastal AI võtme ja mudeli sätted.</string>
<string name="ai_error_add_provider_key">Lisage a %1$s API sisestage AI võtme ja mudeli sätted.</string>
<string name="ai_error_choose_model">Vali mudel %1$s AI võtmete ja mudelite sätetes.</string>
<string name="ai_error_add_provider_key">Lisa %1$s API-võti AI võtmete ja mudelite sätetes.</string>
<string name="ai_error_provider_empty_response">AI pakkuja andis tühja vastuse.</string>
<string name="ai_error_provider_error">AI pakkuja viga: %1$d. %2$s</string>
<string name="ai_error_gemini_required_for_image_summary">See kokkuvõte vajab Gemini mudelit, kuna valitud Groqi mudelid ei toeta PDF/pildisisendit.</string>
@ -1045,7 +1052,7 @@
<string name="ai_settings_recaps_desc">Kasutatakse lugude kokkuvõtete genereerimiseks.</string>
<string name="ai_settings_cloud_tts_desc">Kasutab salvestatud Gemini võti. Ainult %1$s on praegu toetatud.</string>
<string name="dialog_save_provider_key">Salvesta %1$s võti?</string>
<string name="dialog_save_key_desc">Pärast salvestamist on nähtavad ainult esimesed 3 ja 3 viimast tähemärki. Kui soovite seda hiljem muuta, asendage see või kustutage see.</string>
<string name="dialog_save_key_desc">Pärast salvestamist on nähtavad ainult esimesed 3 ja viimased 3 märki. Hiljem muutmiseks asenda või kustuta võti.</string>
<string name="dialog_delete_provider_key">Kustuta %1$s võti?</string>
<string name="dialog_delete_key_desc">Seda teenusepakkujat kasutavad funktsioonid lakkavad töötamast kuni uue võtme salvestamiseni.</string>
<string name="ai_settings_no_key_saved">Võti pole salvestatud</string>
@ -1058,7 +1065,7 @@
<string name="theme_textured">Tekstuuriga</string>
<string name="theme_custom_solid_default">Kohandatud tahke</string>
<string name="theme_custom_textured_default">Kohandatud tekstuuriga</string>
<string name="theme_select_custom_texture">Valige Kohandatud tekstuur</string>
<string name="theme_select_custom_texture">Vali Kohandatud tekstuur</string>
<string name="app_theme_text_brightness_light">Teksti heledus (hele)</string>
<string name="app_theme_text_brightness_dark">Teksti heledus (tume)</string>
<string name="label_default">Vaikimisi</string>
@ -1094,7 +1101,7 @@
<string name="toolbar_hidden_tools">Peidetud tööriistad</string>
<string name="toolbar_more_menu">Rohkem menüüd</string>
<string name="toolbar_hidden_tools_menu">Varjatud tööriistad</string>
<string name="toolbar_drop_tools_here">Pange tööriistad siia</string>
<string name="toolbar_drop_tools_here">Pane tööriistad siia</string>
<string name="content_desc_drag_to_reorder">Lohistage ümberjärjestamiseks</string>
<string name="tool_external_apps">Välised rakendused</string>
<string name="tool_navigation_slider">Navigeerimisliugur</string>
@ -1145,7 +1152,7 @@
<string name="book_replacements_empty_replacement">tühi tekst</string>
<string name="language_dutch">Holland (hollandi)</string>
<string name="language_ukrainian">Українська (ukraina)</string>
<string name="language_indonesian">indoneesia (indoneesia)</string>
<string name="language_indonesian">Bahasa Indonesia (indoneesia)</string>
<string name="desktop_about">Umbes</string>
<string name="desktop_about_subtitle">Lauaarvuti lugeja</string>
<string name="desktop_access">Juurdepääs töölauale</string>
@ -1159,10 +1166,10 @@
<string name="desktop_cache_format">Vahemälu: %1$s</string>
<string name="desktop_cached">Vahemällu salvestatud</string>
<string name="desktop_cached_summary">Vahemällu salvestatud kokkuvõte</string>
<string name="desktop_choose_cloud_tts_voice">Valige Gemini hääl, mida kasutatakse pilve ettelugemiseks.</string>
<string name="desktop_choose_cloud_tts_voice">Vali Gemini hääl, mida kasutatakse pilve ettelugemiseks.</string>
<string name="desktop_clear_book_cache_desc">Kustutage loodud töölauaraamat ja EPUB lehekülgede vahemälu failid? Järgmisel raamatute avamisel luuakse need uuesti.</string>
<string name="desktop_clear_voice_cache">Tühjendage hääle vahemälu</string>
<string name="desktop_close_tools">Sulgege tööriistad</string>
<string name="desktop_clear_voice_cache">Tühjenda hääle vahemälu</string>
<string name="desktop_close_tools">Sulge tööriistad</string>
<string name="desktop_cloud_sync">Pilvesünkroonimine</string>
<string name="desktop_cloud_tts_needs_gemini">Pilv TTS vajab Gemini</string>
<string name="desktop_cloud_tts_needs_signed_in_credits">Pilv TTS vajab sisselogitud krediiti</string>
@ -1179,12 +1186,12 @@
<string name="desktop_custom_fonts_desc">Imporditud fondid lugeja jaoks</string>
<string name="desktop_delete_font">Kustuta font</string>
<string name="desktop_delete_font_desc">Kustuta %1$s? Seda kasutavad raamatud naasevad vaikefondile.</string>
<string name="desktop_delete_shelf_desc">Kas kustutada \&quot;%1$s\&quot;? Raamatud jäävad teie raamatukogusse.</string>
<string name="desktop_delete_shelf_desc">Kas kustutada \&quot;%1$s\&quot;? Raamatud jäävad sinu raamatukogusse.</string>
<string name="desktop_delete_summary">Kustuta kokkuvõte</string>
<string name="desktop_disabled">Keelatud</string>
<string name="desktop_drop_files_to_import">Pukseerige failid importimiseks</string>
<string name="desktop_drop_supported_files_to_import">Eemaldage importimiseks toetatud failid</string>
<string name="desktop_email_support_desc">Kui soovite midagi muud, võtke meiega otse e-posti teel ühendust.</string>
<string name="desktop_drop_files_to_import">Pukseeri failid importimiseks</string>
<string name="desktop_drop_supported_files_to_import">Eemalda importimiseks toetatud failid</string>
<string name="desktop_email_support_desc">Kui vajad midagi muud, võta meiega otse e-posti teel ühendust.</string>
<string name="desktop_equals">Võrdub</string>
<string name="desktop_extras">Lisad</string>
<string name="desktop_feedback">Tagasiside</string>
@ -1195,36 +1202,36 @@
<string name="desktop_free_remaining_format">Tasuta, %1$d vasakule</string>
<string name="desktop_generate_recap">Loo kokkuvõte</string>
<string name="desktop_generate_summary">Loo kokkuvõte</string>
<string name="desktop_get_in_touch_desc">Teatage vigadest, taotlege funktsioone või võtke otse ühendust toega.</string>
<string name="desktop_get_in_touch_desc">Teata vigadest, taotle funktsioone või võta toega otse ühendust.</string>
<string name="desktop_github_sponsors">GitHubi sponsorid</string>
<string name="desktop_github_sponsors_desc">Toetage arengut GitHubi sponsorite kaudu.</string>
<string name="desktop_github_sponsors_desc">Toeta arendust GitHub Sponsorsi kaudu.</string>
<string name="desktop_google_sign_in_not_configured">Google sisselogimine pole selle töölauajärgu jaoks konfigureeritud.</string>
<string name="desktop_greater_than">Suurem kui</string>
<string name="desktop_help">Abi</string>
<string name="desktop_help_feedback_desc">Veaaruanded, funktsioonitaotlused ja tugi</string>
<string name="desktop_hide">Peida</string>
<string name="desktop_import_files">Importige faile</string>
<string name="desktop_import_files">Impordi faile</string>
<string name="desktop_issues">Probleemid</string>
<string name="desktop_issues_desc">Avage probleemide jälgija vigade ja funktsioonitaotluste jaoks.</string>
<string name="desktop_issues_desc">Ava probleemide jälgija vigade ja funktsioonisoovide jaoks.</string>
<string name="desktop_less_than">Vähem kui</string>
<string name="desktop_library_and_reader">Raamatukogu ja lugeja</string>
<string name="desktop_match_any">Ükskõik milline</string>
<string name="desktop_more_library_actions">Raamatukogu tegevused</string>
<string name="desktop_more_menu">Rohkem</string>
<string name="desktop_no_cached_summaries_book">Selle raamatu kohta pole veel vahemällu salvestatud kokkuvõtteid.</string>
<string name="desktop_no_custom_fonts_desc">Importige TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada.</string>
<string name="desktop_no_custom_fonts_desc">Impordi TTF-, OTF- või WOFF2-faile, et neid raamatutes kasutada.</string>
<string name="desktop_no_fonts_matching">Ei leitud fonte, mis vastavad \&quot;%1$s\&quot;</string>
<string name="desktop_no_google_account_connected">Nr Google konto on ühendatud.</string>
<string name="desktop_no_summary_cached_section">Selle jaotise kohta pole vahemällu salvestatud kokkuvõtet.</string>
<string name="desktop_offline_oss_reader">Võrguühenduseta lauaarvuti lugeja</string>
<string name="desktop_open_readers">Avatud lugejad</string>
<string name="desktop_opening_title">Avamine %1$s</string>
<string name="desktop_opening_your_library">Teie raamatukogu avamine</string>
<string name="desktop_opening_your_library">Raamatukogu avamine</string>
<string name="desktop_operator">Operaator</string>
<string name="desktop_page">Lehekülg</string>
<string name="desktop_password_protected_pdf">Parooliga kaitstud PDF</string>
<string name="desktop_patreon">Patreon</string>
<string name="desktop_patreon_desc">Toetage projekti Patreonis.</string>
<string name="desktop_patreon_desc">Toeta projekti Patreonis.</string>
<string name="desktop_paused">Peatatud</string>
<string name="desktop_pdf_password_required_desc">%1$s nõuab enne avamist parooli.</string>
<string name="desktop_pdf_password_required_or_incorrect">Parool on nõutav või vale.</string>
@ -1269,13 +1276,13 @@
<string name="desktop_view">Vaade</string>
<string name="desktop_voice_cache">Hääle vahemälu</string>
<string name="desktop_webview_preparing">Manustatud veebivaate ettevalmistamine…</string>
<string name="desktop_webview_preparing_progress">Preparing bundled embedded webview %1$d%%</string>
<string name="desktop_webview_preparing_progress">Pakitud manustatud veebivaate ettevalmistamine %1$d%%</string>
<string name="desktop_webview_restart_required">Manustatud veebivaade installitud. Taaskäivitage Episteme seadistamise lõpetamiseks.</string>
<string name="desktop_webview_start_error">Embedded webview could not start: %1$s</string>
<string name="desktop_webview_start_error">Manustatud veebivaadet ei saanud käivitada: %1$s</string>
<string name="desktop_working">Töötab…</string>
<string name="desktop_workspace">Tööruum</string>
<string name="desktop_add_to_shelf">Lisa riiulile</string>
<string name="desktop_create_shelf_first">Create a shelf first, then add selected books to it.</string>
<string name="desktop_create_shelf_first">Loo esmalt riiul ja lisa siis valitud raamatud sinna.</string>
<string name="desktop_create_theme">Loo teema</string>
<string name="desktop_existing_tags_format">Olemasolev: %1$s</string>
<string name="desktop_external_link_desc">Klõpsasite välisel lingil.</string>
@ -1291,20 +1298,20 @@
<string name="desktop_annotation_options">Märkuste valikud</string>
<string name="desktop_annotation_tools">Märkuste tegemise tööriistad</string>
<string name="desktop_assist">Abi</string>
<string name="desktop_choose_pdf_to_save">Valige, milline PDF päästa.</string>
<string name="desktop_choose_pdf_to_save">Vali, milline PDF päästa.</string>
<string name="desktop_clear_jump_history">Tühjenda hüppeajalugu</string>
<string name="desktop_cloud_tts_failed">Pilv TTS ebaõnnestunud.</string>
<string name="desktop_cloud_tts_needs_gemini_key_desc">Lisage a Gemini klahvi ja valige Gemini pilv TTS aastal AI võtmed ja mudelid.</string>
<string name="desktop_cloud_tts_needs_gemini_key_desc">Lisa Gemini võti ja vali Gemini pilve-TTS jaotises AI võtmed ja mudelid.</string>
<string name="desktop_cloud_tts_not_configured_desc">Pilv TTS pole selle töölaua järgu jaoks konfigureeritud.</string>
<string name="desktop_cloud_tts_sign_in_required_desc">Logige sisse rakendusega Google to use cloud TTS.</string>
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Pilv TTS needs a signed-in account with credits. Pro ja krediite saab osta ainult veebisaidilt Android rakendus.</string>
<string name="desktop_cloud_tts_sign_in_required_desc">Pilve-TTS-i kasutamiseks logi Googleiga sisse.</string>
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Pilve-TTS vajab sisselogitud krediitidega kontot. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_color">Värv</string>
<string name="desktop_comment_options">Kommentaaride valikud</string>
<string name="desktop_custom_theme_default">Kohandatud</string>
<string name="desktop_delete_annotation_desc">See eemaldab märkuse sellelt PDF.</string>
<string name="desktop_delete_annotation_title">Kas kustutada märkus?</string>
<string name="desktop_document_text">Dokumendi tekst</string>
<string name="desktop_embedded_pdf_comment">Manustatud PDF kommenteerida</string>
<string name="desktop_embedded_pdf_comment">Manustatud PDF-i kommentaar</string>
<string name="desktop_failed_render_page">Lehe renderdamine ebaõnnestus.</string>
<string name="desktop_feature_unavailable">Funktsioon pole saadaval</string>
<string name="desktop_finished">Valmis</string>
@ -1330,14 +1337,14 @@
<string name="desktop_no_text_to_summarize">There is no text to summarize.</string>
<string name="desktop_open_comment">Ava kommentaar</string>
<string name="desktop_out_of_credits_android_purchase_desc">Krediidid otsas. Pro ja krediite saab osta ainult Android rakendus.</string>
<string name="desktop_out_of_credits_cloud_tts_desc">Pilve kasutamine TTS needs credits on desktop. Pro ja krediite saab osta ainult veebisaidilt Android rakendus.</string>
<string name="desktop_out_of_credits_generic_feature_desc">Using this feature needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
<string name="desktop_out_of_credits_recaps_desc">Using recaps needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
<string name="desktop_out_of_credits_summaries_desc">Using summaries needs credits on desktop. Pro ja krediite saab osta ainult Android rakendus.</string>
<string name="desktop_pan">Pan</string>
<string name="desktop_pdf_action_failed">PDF tegevus ebaõnnestus</string>
<string name="desktop_pdf_action_failed_desc">PDF action could not be completed.</string>
<string name="desktop_pdf_comment">PDF kommenteerida</string>
<string name="desktop_out_of_credits_cloud_tts_desc">Pilve-TTS vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_out_of_credits_generic_feature_desc">See funktsioon vajab töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_out_of_credits_recaps_desc">Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_out_of_credits_summaries_desc">Kokkuvõtted vajavad töölaual krediite. Pro ja krediite saab osta ainult Androidi rakendusest.</string>
<string name="desktop_pan">Panoraami</string>
<string name="desktop_pdf_action_failed">PDF-i toiming ebaõnnestus</string>
<string name="desktop_pdf_action_failed_desc">PDF-i toimingut ei saanud lõpule viia.</string>
<string name="desktop_pdf_comment">PDF-i kommentaar</string>
<string name="desktop_pdf_compact_page_number">lk. %1$d</string>
<string name="desktop_pdf_page_content_desc">PDF leht %1$d</string>
<string name="desktop_pdf_page_author_format">Lehekülg %1$d - %2$s</string>
@ -1359,15 +1366,15 @@
<string name="desktop_remove_gap_between_pages_desc">Kehtib vertikaalsel lugemisel ja kaheleheküljelistel laialitel.</string>
<string name="desktop_round_highlighter">Ümmargune highlighter</string>
<string name="desktop_saved_to_path_format">Salvestatud asukohta %1$s</string>
<string name="desktop_scroll">Kerige</string>
<string name="desktop_scroll">Keri</string>
<string name="desktop_search_in_pdf">Otsi: PDF</string>
<string name="desktop_select_text">Valige tekst</string>
<string name="desktop_select_text">Vali tekst</string>
<string name="desktop_selected_annotation_format">Valitud %1$s</string>
<string name="desktop_show_search_results">Kuva otsingutulemused</string>
<string name="desktop_sign_in_required_generic_feature_desc">Logige sisse rakendusega Google selle funktsiooni kasutamiseks töölaual.</string>
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Logige sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual.</string>
<string name="desktop_sign_in_required_recaps_desc">Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
<string name="desktop_sign_in_required_summaries_desc">Logige sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
<string name="desktop_sign_in_required_generic_feature_desc">Logi sisse rakendusega Google selle funktsiooni kasutamiseks töölaual.</string>
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Logi sisse rakendusega Google mitmesõnalise nutika sõnastiku kasutamiseks töölaual.</string>
<string name="desktop_sign_in_required_recaps_desc">Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
<string name="desktop_sign_in_required_summaries_desc">Logi sisse rakendusega Google töölaual kokkuvõtete kasutamiseks.</string>
<string name="desktop_stopped">Peatatud</string>
<string name="desktop_text_note">Tekstimärkus</string>
<string name="desktop_text_note_lowercase">tekstimärkus</string>
@ -1394,13 +1401,13 @@
<string name="desktop_book_badge_folder">Kaust</string>
<string name="desktop_browse">Sirvige</string>
<string name="desktop_categories">Kategooriad</string>
<string name="desktop_chapter_short_format">Ch. %1$d</string>
<string name="desktop_chapter_turns">Peatükk Pöörded</string>
<string name="desktop_choose_font">Valige font</string>
<string name="desktop_choose_reader_texture">Valige lugeja tekstuur</string>
<string name="desktop_chapter_short_format">Ptk %1$d</string>
<string name="desktop_chapter_turns">Peatüki pöörded</string>
<string name="desktop_choose_font">Vali font</string>
<string name="desktop_choose_reader_texture">Vali lugeja tekstuur</string>
<string name="desktop_clear_file_types">Kustuta failitüübid</string>
<string name="desktop_clear_page_annotations">Lehekülje märkuste kustutamine</string>
<string name="desktop_clear_sources">Selged allikad</string>
<string name="desktop_clear_sources">Tühjenda allikad</string>
<string name="desktop_clear_status">Tühjenda olek</string>
<string name="desktop_clear_tags">Tühjenda sildid</string>
<string name="desktop_close_reader">Sule lugeja</string>
@ -1427,16 +1434,16 @@
<string name="desktop_label_pair_format">%1$s - %2$s</string>
<string name="desktop_hide_filters">Peida filtrid</string>
<string name="desktop_hide_reader_tools">Peida lugeja tööriistad</string>
<string name="desktop_highlight_palette_hint">Puudutage pesa ja seejärel valige värv.</string>
<string name="desktop_home_subtitle">Jätkake lugemist ja hiljutisi raamatuid</string>
<string name="desktop_import_books">Importige raamatuid</string>
<string name="desktop_highlight_palette_hint">Puuduta pesa ja vali värv.</string>
<string name="desktop_home_subtitle">Jätka lugemist ja vaata hiljutisi raamatuid</string>
<string name="desktop_import_books">Impordi raamatuid</string>
<string name="desktop_import_folder">Impordi kaust</string>
<string name="desktop_imported_fonts">Imporditud fondid</string>
<string name="desktop_import_result_pair">%1$s %2$s</string>
<string name="desktop_increase_format">Suurendada %1$s</string>
<string name="desktop_jump_history">Hüppe ajalugu</string>
<string name="desktop_layout_spacing">Paigutus ja vahekaugus</string>
<string name="desktop_library_empty_desc">Importige failid rakenduste salvestusruumi või lisage failide lugemiseks kaust.</string>
<string name="desktop_library_empty_desc">Impordi failid rakenduse salvestusruumi või lisa failide lugemiseks kaust.</string>
<string name="desktop_library_subtitle">Sirvige oma kollektsiooni</string>
<string name="desktop_ai_keys">AI võtmed</string>
<string name="desktop_library_tab_smart_shelves_count">Nutikas %1$d</string>

View file

@ -1512,4 +1512,6 @@
<string name="tts_replacements_replace_only_spoken">Chỉ thay thế nội dung được đọc</string>
<string name="tts_replacements_replace_only_spoken_desc">Văn bản trình đọc, tô sáng và vị trí vẫn không đổi.</string>
<string name="tts_replacements_summary_format">%1$s -&gt; %2$s</string>
<string name="error_copy_to_clipboard">Không thể sao chép vào khay nhớ tạm</string>
<string name="error_print_password_protected">Không thể in tệp PDF được bảo vệ bằng mật khẩu</string>
</resources>

View file

@ -201,8 +201,13 @@
<string name="external_file_keep">Keep in Library</string>
<string name="external_file_delete">Remove</string>
<string name="external_file_behavior_ask">Ask Every Time</string>
<string name="external_file_behavior_ask_desc">After closing an externally opened file, ask whether to keep it in the library or remove it.</string>
<string name="external_file_behavior_keep">Always Keep</string>
<string name="external_file_behavior_keep_desc">Externally opened files are copied into the library and kept after closing.</string>
<string name="external_file_behavior_delete">Always Remove</string>
<string name="external_file_behavior_delete_desc">Externally opened files are copied for reading, then removed after closing.</string>
<string name="external_file_behavior_temporary">Open Temporarily</string>
<string name="external_file_behavior_temporary_desc">Open directly from the source app in a temporary reader. Back returns to that app without adding the file to the library.</string>
<string name="options_external_file_behavior">External File Behavior</string>
<!-- OPDS — "OPDS" is a technical protocol name, do not translate it. -->
@ -480,10 +485,14 @@
<string name="banner_saving_original_pdf">Saving original PDF…</string>
<!-- PDF = file format name — do not translate. -->
<string name="banner_original_pdf_saved">Original PDF saved successfully.</string>
<string name="banner_saving_original_file">Saving original file...</string>
<string name="banner_original_file_saved">Original file saved successfully.</string>
<string name="error_saving_file">Error saving file: %1$s</string>
<!-- Email/share subject line. %1$s = the name of the file being shared. Example: "Sharing: Dracula.pdf". -->
<string name="share_subject">Sharing: %1$s</string>
<!-- PDF = file format name — do not translate. -->
<string name="share_chooser_title">Share PDF</string>
<string name="share_file_chooser_title">Share file</string>
<!-- Share failure banner. %1$s = error reason string from the system. Example: "Share failed: No app found". -->
<string name="error_share_failed">Share failed: %1$s</string>
<!-- Folder sync limit error. %1$d = the maximum number of folders allowed. Example: "Limit reached: Maximum 3 folders allowed." -->
@ -1054,6 +1063,8 @@
<!-- PdfViewerScreen & General Reader -->
<string name="error_open_print_settings">Could not open print settings</string>
<string name="error_copy_to_clipboard">Could not copy to clipboard</string>
<string name="error_print_password_protected">Password protected PDF files cannot be printed</string>
<!-- PDF = file format name — do not translate. -->
<string name="loading_pdf">Loading PDF…</string>
<!-- PDF = file format name — do not translate. -->
@ -1194,6 +1205,7 @@
<string name="language_korean">한국어 (Korean)</string>
<string name="language_hindi">हिन्दी (Hindi)</string>
<string name="language_chinese_simplified">简体中文 (Chinese, Simplified)</string>
<string name="language_estonian">Eesti (Estonian)</string>
<!-- App-wide theme controls in HomeScreen.kt. -->
<string name="app_theme_title">App Theme</string>

View file

@ -19,4 +19,5 @@
<locale android:name="ko"/>
<locale android:name="hi"/>
<locale android:name="zh-CN"/>
<locale android:name="et"/>
</locale-config>

View file

@ -12,16 +12,28 @@ class AndroidStringFormatResourcesTest {
@Test
fun `vietnamese strings cover translatable base resources`() {
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-vi", localeName = "Vietnamese")
}
@Test
fun `estonian strings cover translatable base resources`() {
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-et", localeName = "Estonian")
}
private fun assertLocaleCoversTranslatableBaseResources(
localeDirectory: String,
localeName: String
) {
val resDirectory = findResDirectory()
val baseNames = readResourceNames(
stringsFile = File(resDirectory, "values/strings.xml"),
includeNonTranslatable = false
)
val vietnameseNames = readResourceNames(File(resDirectory, "values-vi/strings.xml"))
val missingNames = baseNames.filterNot { it in vietnameseNames }
val localizedNames = readResourceNames(File(resDirectory, "$localeDirectory/strings.xml"))
val missingNames = baseNames.filterNot { it in localizedNames }
assertTrue(
"Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}",
"Missing $localeName strings:\n${missingNames.joinToString(separator = "\n")}",
missingNames.isEmpty()
)
}

View file

@ -15,11 +15,11 @@ class AppLanguageOptionsTest {
assertEquals(
listOf(
"en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl",
"id", "vi", "ja", "ko", "hi", "zh-CN"
"id", "vi", "ja", "ko", "hi", "zh-CN", "et"
),
supportedAppLanguageOptions.mapNotNull { it.tag }
)
assertEquals(R.string.language_chinese_simplified, supportedAppLanguageOptions.last().labelRes)
assertEquals(R.string.language_estonian, supportedAppLanguageOptions.last().labelRes)
}
@Test
@ -51,6 +51,7 @@ class AppLanguageOptionsTest {
val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" }
val japanese = supportedAppLanguageOptions.first { it.tag == "ja" }
val korean = supportedAppLanguageOptions.first { it.tag == "ko" }
val estonian = supportedAppLanguageOptions.first { it.tag == "et" }
assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce"))
assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch"))
@ -66,6 +67,7 @@ class AppLanguageOptionsTest {
assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet"))
assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo"))
assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul"))
assertTrue(estonian.matchesLanguageSearch(label = "Eesti", query = "eesti"))
}
@Test

View file

@ -0,0 +1,17 @@
package com.aryan.reader
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ClipboardUtilsTest {
@Test
fun `set primary clip reports success`() {
assertTrue(setPrimaryClipSafely {})
}
@Test
fun `set primary clip handles security rejection`() {
assertFalse(setPrimaryClipSafely { throw SecurityException("denied") })
}
}

View file

@ -0,0 +1,28 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ExternalFileOpenRouteDeciderTest {
@Test
fun `temporary behavior routes to temporary activity`() {
assertTrue(ExternalFileOpenRouteDecider.shouldOpenTemporary("TEMPORARY"))
assertEquals(
TemporaryExternalFileActivity::class.java,
ExternalFileOpenRouteDecider.targetActivityClass("TEMPORARY")
)
}
@Test
fun `existing behaviors route to main activity`() {
listOf(null, "ASK", "KEEP", "DELETE").forEach { behavior ->
assertFalse(ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior))
assertEquals(
MainActivity::class.java,
ExternalFileOpenRouteDecider.targetActivityClass(behavior)
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader
import android.app.Application
import android.content.ContentResolver
import android.content.SharedPreferences
import android.content.res.Resources
import android.net.Uri
@ -22,6 +23,7 @@ import com.aryan.reader.tts.TtsPlaybackManager
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@ -45,6 +47,7 @@ class MainViewModelTest {
private lateinit var mockApplication: Application
private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor
private val prefsStringSets = mutableMapOf<String, Set<String>>()
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
@ -64,6 +67,12 @@ class MainViewModelTest {
}
private class TestMainViewModel(application: Application) : MainViewModel(application) {
val locallyCleanedBookIds = mutableListOf<String>()
override suspend fun cleanupBookDataLocally(bookId: String) {
locallyCleanedBookIds += bookId
}
fun clearForTest() {
ViewModel::class.java
.getDeclaredMethod("clear\$lifecycle_viewmodel_release")
@ -83,6 +92,7 @@ class MainViewModelTest {
billingStateFlow.value = ProUpgradeState()
customFontsFlow.value = emptyList()
ttsStateFlow.value = TtsPlaybackManager.TtsState()
prefsStringSets.clear()
mockkStatic(Log::class)
every { Log.isLoggable(any(), any()) } returns false
@ -109,9 +119,14 @@ class MainViewModelTest {
every { mockApplication.filesDir } returns filesDir
every { mockApplication.cacheDir } returns cacheDir
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
every { mockApplication.getString(any()) } answers { "res-${firstArg<Int>()}" }
every { mockApplication.getString(any(), *anyVararg()) } answers { "res-${firstArg<Int>()}" }
every { mockPrefs.edit() } returns mockEditor
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
every { mockPrefs.getStringSet(any(), any()) } answers {
prefsStringSets[firstArg<String>()]?.toMutableSet() ?: secondArg<Set<String>?>()?.toMutableSet()
}
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
@ -174,6 +189,7 @@ class MainViewModelTest {
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().addRecentFile(any()) } just Runs
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
@ -417,38 +433,39 @@ class MainViewModelTest {
viewModel.setStrictFileFilter(true)
viewModel.setUsePdfFileNameAsDisplayName(true)
viewModel.setExternalFileBehavior("KEEP")
viewModel.setExternalFileBehavior("TEMPORARY")
val state = viewModel.uiState.first {
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP"
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "TEMPORARY"
}
assertTrue(state.useStrictFileFilter)
assertTrue(state.usePdfFileNameAsDisplayName)
assertEquals("KEEP", state.externalFileBehavior)
assertEquals("TEMPORARY", state.externalFileBehavior)
verify { mockEditor.putBoolean("use_strict_file_filter", true) }
verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) }
verify { mockEditor.putString("external_file_behavior", "KEEP") }
verify { mockEditor.putString("external_file_behavior", "TEMPORARY") }
}
@Test
fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) {
val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub"
val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}"""
every {
mockPrefs.getStringSet("pending_external_file_removals", any())
} returns mutableSetOf(pendingEntry)
prefsStringSets["pending_external_file_removals"] = setOf(pendingEntry)
every { mockPrefs.getString("last_open_book_id", null) } returns "external-book"
every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name
val restored = TestMainViewModel(mockApplication)
try {
advanceUntilIdle()
coVerify {
coVerify(timeout = 1_000) {
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
}
coVerify {
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
}
assertEquals(listOf("external-book"), restored.locallyCleanedBookIds)
verify(atLeast = 1) { mockEditor.remove("last_open_book_id") }
verify(atLeast = 1) { mockEditor.remove("last_open_file_type") }
verify { mockEditor.remove("pending_external_file_removals") }
@ -457,6 +474,60 @@ class MainViewModelTest {
}
}
@Test
fun `temporary external pdf opens directly without importing or adding to library`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val externalUri = mockUri("content://external/temp.pdf", path = "/temp.pdf", lastPathSegment = "temp.pdf")
val resolver = mockk<ContentResolver>()
every { mockApplication.contentResolver } returns resolver
every { resolver.getType(externalUri) } returns "application/pdf"
every { resolver.query(externalUri, null, null, null, null) } returns null
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(match { it.startsWith("temporary-") }) } returns null
viewModel.onFileSelected(
externalUri,
isFromRecent = false,
isExternalIntent = true,
isTemporaryExternalIntent = true
)
advanceUntilIdle()
val selected = viewModel.uiState.first { it.selectedBookId?.startsWith("temporary-") == true && it.selectedPdfUri != null }
assertEquals(externalUri, selected.selectedPdfUri)
assertEquals(null, selected.showExternalFileSavePromptFor)
coVerify(exactly = 0) { anyConstructed<BookImporter>().importBook(any()) }
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().addRecentFile(any()) }
verify(exactly = 0) { mockEditor.putStringSet("pending_external_file_removals", any()) }
}
@Test
fun `closing temporary external direct book signals activity finish without library cleanup`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val item = recentFile("external-book", type = FileType.PDF)
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(item.bookId) } returns item
viewModel.trackExternalOpenForClose(
bookId = item.bookId,
importedCopyUriString = null,
isTemporaryExternalIntent = true
)
viewModel.onRecentFileClicked(item)
advanceUntilIdle()
viewModel.uiState.first { it.selectedBookId == item.bookId }
val finishEvent = backgroundScope.async { viewModel.temporaryExternalOpenFinished.first() }
viewModel.clearSelectedFile()
advanceUntilIdle()
assertEquals(null, viewModel.uiState.value.showExternalFileSavePromptFor)
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf(item.bookId)) }
coVerify(exactly = 0) { anyConstructed<BookImporter>().deleteBookByUriString(item.uriString!!) }
assertTrue(finishEvent.isCompleted)
}
@Test
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {

View file

@ -0,0 +1,22 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Test
class ReaderPopupSizingTest {
@Test
fun `modal max height leaves edge margin on landscape-height screens`() {
assertEquals(306, readerModalMaxHeightDp(screenHeightDp = 360))
}
@Test
fun `modal max height uses preferred minimum when there is room`() {
assertEquals(220, readerModalMaxHeightDp(screenHeightDp = 252))
}
@Test
fun `modal max height stays within tiny screens`() {
assertEquals(168, readerModalMaxHeightDp(screenHeightDp = 200))
}
}

View file

@ -0,0 +1,32 @@
package com.aryan.reader.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ImportedFontFileNameTest {
@Test
fun importedFontFileNamePreservesVariableFontVariantTokens() {
val fileName = importedFontFileName(
displayName = "Pliant-Italic-VariableFont_wdth,wght",
extension = "TTF"
)
assertEquals("Pliant-Italic-VariableFont_wdth,wght.ttf", fileName)
}
@Test
fun importedFontFileNameRemovesPathUnsafeCharacters() {
val fileName = importedFontFileName(
displayName = """Pliant/Italic:VariableFont*wdth?wght""",
extension = "t/tf"
)
assertEquals("Pliant_Italic_VariableFont_wdth_wght.ttf", fileName)
}
@Test
fun importedFontFileNameFallsBackForBlankNames() {
assertTrue(importedFontFileName("...", "ttf").startsWith("font."))
}
}

View file

@ -105,6 +105,26 @@ class RecentFileDaoReadingPositionTest {
assertTrue(item.isRecent)
}
@Test
fun `recent file summary caps oversized descriptions while full lookup keeps metadata`() = runTest {
val longDescription = "Summary ".repeat(2_000)
val longOriginalDescription = "Original ".repeat(2_000)
dao.insertOrUpdateFile(
recentFileEntity().copy(
description = longDescription,
originalDescription = longOriginalDescription
)
)
val summary = dao.getRecentFiles().first().single()
val full = dao.getFileByBookId("book-1")!!
assertEquals(4_096, summary.description?.length)
assertEquals(4_096, summary.originalDescription?.length)
assertEquals(longDescription, full.description)
assertEquals(longOriginalDescription, full.originalDescription)
}
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
return RecentFileEntity(
bookId = "book-1",

View file

@ -104,6 +104,30 @@ class EpubParserUnitTest {
assertTrue(extractionDir.list().isNullOrEmpty())
}
@Test
fun `createEpubBook uses spine toc id when manifest contains volume ncx files first`() = runTest {
val cacheDir = temp.newFolder("cache-merged-toc")
val extractionDir = temp.newFolder("extract-merged-toc")
val parser = EpubParser(contextWithCache(cacheDir))
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(mergedVolumeTocEpubBytes()),
bookId = "book-id",
shouldUseToc = true,
originalBookNameHint = "merged.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
assertEquals(
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
book.tableOfContents.map { it.label }
)
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
assertEquals("Volume 2", book.chapters[2].title)
assertEquals("Chapter 2", book.chapters[3].title)
}
@Test
fun `metadata only extraction streams images to disk without retaining image bytes`() {
val cacheDir = temp.newFolder("cache-metadata-stream")
@ -449,6 +473,50 @@ class EpubParserUnitTest {
"OEBPS/images/unlisted.png" to "not-real-image"
)
private fun mergedVolumeTocEpubBytes(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Merged Volumes</dc:title></metadata>
<manifest>
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
</manifest>
<spine toc="ncx">
<itemref idref="v1title"/>
<itemref idref="v1c1"/>
<itemref idref="v2title"/>
<itemref idref="v2c1"/>
</spine>
</package>
""".trimIndent(),
"OEBPS/1/toc.ncx" to """
<ncx><navMap>
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
</navMap></ncx>
""".trimIndent(),
"OEBPS/toc.ncx" to """
<ncx><navMap>
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
</navPoint>
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
</navPoint>
</navMap></ncx>
""".trimIndent(),
"OEBPS/1/title.xhtml" to "<html><body><h1>HTML Volume 1</h1><p>Volume one.</p></body></html>",
"OEBPS/1/chapter1.xhtml" to "<html><body><h1>HTML Chapter 1</h1><p>Chapter one.</p></body></html>",
"OEBPS/2/title.xhtml" to "<html><body><h1>HTML Volume 2</h1><p>Volume two.</p></body></html>",
"OEBPS/2/chapter1.xhtml" to "<html><body><h1>HTML Chapter 2</h1><p>Chapter two.</p></body></html>"
)
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>

View file

@ -0,0 +1,29 @@
package com.aryan.reader.epubreader
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class EpubReaderTtsHighlightAssetTest {
@Test
fun `tts highlight is constrained to one readable block and does not inherit spacing`() {
val js = epubReaderAsset().readText()
assertTrue(js.contains("const TTS_HIGHLIGHT_BLOCK_SELECTOR"))
assertTrue(js.contains("getTtsHighlightBlock(baseNode)"))
assertTrue(js.contains("document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT"))
assertTrue(js.contains("text-align-last: auto !important;"))
assertTrue(js.contains("letter-spacing: normal !important;"))
assertTrue(js.contains("word-spacing: normal !important;"))
}
private fun epubReaderAsset(): File {
val candidates = listOf(
File("src/main/assets/epub_reader.js"),
File("app/src/main/assets/epub_reader.js")
)
return candidates.firstOrNull { it.isFile }
?: error("Unable to locate epub_reader.js from ${File(".").absolutePath}")
}
}

View file

@ -62,4 +62,45 @@ class EpubTtsChunkMatchingTest {
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
}
@Test
fun `vertical continuation falls back to loaded chunk boundary when resume match is unavailable`() {
val chunks = listOf(
TtsChunk("Loaded one", "/4/2", 0),
TtsChunk("Loaded two", "/4/4", 0),
TtsChunk("Remaining three", "/4/6", 0),
TtsChunk("Remaining four", "/4/8", 0)
)
assertEquals(
2,
resolveTtsContinuationStartIndex(
chunks = chunks,
loadedChunkCount = 2,
sourceCfi = "/does/not/match",
startOffsetInSource = 0,
currentText = "not present"
)
)
}
@Test
fun `vertical continuation starts after matched spoken chunk`() {
val chunks = listOf(
TtsChunk("Loaded one", "/4/2", 0),
TtsChunk("Loaded two", "/4/4", 0),
TtsChunk("Remaining three", "/4/6", 0)
)
assertEquals(
2,
resolveTtsContinuationStartIndex(
chunks = chunks,
loadedChunkCount = 1,
sourceCfi = "/4/4",
startOffsetInSource = 0,
currentText = "Loaded two"
)
)
}
}

View file

@ -0,0 +1,75 @@
package com.aryan.reader.paginatedreader
import android.view.KeyEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AndroidEpubKeyCommandsTest {
@Test
fun `left and right map to page changes`() {
assertEquals(
AndroidEpubKeyCommand.PREVIOUS_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_LEFT)
)
assertEquals(
AndroidEpubKeyCommand.NEXT_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT)
)
}
@Test
fun `left and right respect right to left pagination`() {
assertEquals(
AndroidEpubKeyCommand.NEXT_PAGE,
androidEpubKeyCommandOrNull(
KeyEvent.KEYCODE_DPAD_LEFT,
rightToLeftPagination = true
)
)
assertEquals(
AndroidEpubKeyCommand.PREVIOUS_PAGE,
androidEpubKeyCommandOrNull(
KeyEvent.KEYCODE_DPAD_RIGHT,
rightToLeftPagination = true
)
)
}
@Test
fun `up and down map to vertical scroll`() {
assertEquals(
AndroidEpubKeyCommand.SCROLL_UP,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_UP)
)
assertEquals(
AndroidEpubKeyCommand.SCROLL_DOWN,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_DOWN)
)
}
@Test
fun `page home and end keys map to reader navigation`() {
assertEquals(
AndroidEpubKeyCommand.PREVIOUS_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_UP)
)
assertEquals(
AndroidEpubKeyCommand.NEXT_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_DOWN)
)
assertEquals(
AndroidEpubKeyCommand.FIRST_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_HOME)
)
assertEquals(
AndroidEpubKeyCommand.LAST_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_END)
)
}
@Test
fun `ctrl shortcuts are left for reader chrome and search handling`() {
assertNull(androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT, isCtrlPressed = true))
}
}

View file

@ -0,0 +1,128 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class EpubFontFaceSiblingsTest {
@Test
fun expandFontFacesWithSiblings_addsItalicAndBoldItalicVariants() {
val root = createTempRoot()
val fontsDir = File(root, "OEBPS/fonts").apply { mkdirs() }
File(fontsDir, "Literata-Regular.ttf").writeText("regular")
File(fontsDir, "Literata-Italic.ttf").writeText("italic")
File(fontsDir, "Literata-BoldItalic.ttf").writeText("bold italic")
File(fontsDir, "Other-Italic.ttf").writeText("other")
val expanded = expandFontFacesWithSiblings(
fontFaces = listOf(
FontFaceInfo(
fontFamily = "literata",
src = "OEBPS/fonts/Literata-Regular.ttf",
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
)
),
extractionPath = root.absolutePath
)
assertEquals(3, expanded.size)
assertTrue(expanded.any { it.src == "OEBPS/fonts/Literata-Italic.ttf" && it.fontStyle == FontStyle.Italic })
assertTrue(
expanded.any {
it.src == "OEBPS/fonts/Literata-BoldItalic.ttf" &&
it.fontStyle == FontStyle.Italic &&
it.fontWeight == FontWeight.Bold
}
)
assertTrue(expanded.none { it.src.contains("Other") })
}
@Test
fun buildEpubFontFaceCss_emitsVariantDescriptorsForSiblings() {
val root = createTempRoot()
val fontsDir = File(root, "fonts").apply { mkdirs() }
File(fontsDir, "LoraRegular.ttf").writeText("regular")
File(fontsDir, "LoraBoldItalic.ttf").writeText("bold italic")
val css = buildEpubFontFaceCss(
fontFaces = listOf(
FontFaceInfo(
fontFamily = "lora",
src = "fonts/LoraRegular.ttf",
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
)
),
extractionPath = root.absolutePath
)
assertTrue(css.contains("font-family: 'lora'"))
assertTrue(css.contains("font-weight: 700"))
assertTrue(css.contains("font-style: italic"))
assertTrue(css.contains("LoraBoldItalic.ttf"))
}
@Test
fun expandFontFacesWithSiblings_groupsVariableRegularAndItalicFiles() {
val root = createTempRoot()
val fontsDir = File(root, "fonts").apply { mkdirs() }
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
val expanded = expandFontFacesWithSiblings(
fontFaces = listOf(
FontFaceInfo(
fontFamily = "pliant",
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
)
),
extractionPath = root.absolutePath
)
assertEquals(2, expanded.size)
assertTrue(
expanded.any {
it.src == "fonts/Pliant-Italic-VariableFont_wdth,wght.ttf" &&
it.fontStyle == FontStyle.Italic &&
it.fontWeight == FontWeight.Normal
}
)
}
@Test
fun buildEpubFontFaceCss_usesWeightRangeForVariableWeightFonts() {
val root = createTempRoot()
val fontsDir = File(root, "fonts").apply { mkdirs() }
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
val css = buildEpubFontFaceCss(
fontFaces = listOf(
FontFaceInfo(
fontFamily = "pliant",
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
)
),
extractionPath = root.absolutePath
)
assertTrue(css.contains("font-weight: 100 900"))
assertTrue(css.contains("font-style: italic"))
assertTrue(css.contains("Pliant-Italic-VariableFont_wdth,wght.ttf"))
}
private fun createTempRoot(): File {
return kotlin.io.path.createTempDirectory("epub-font-siblings").toFile().also {
it.deleteOnExit()
}
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeVerticalLocationTest {
@ -27,4 +28,157 @@ class NativeVerticalLocationTest {
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
}
@Test
fun `scroll progress updates within visible item offset`() {
val weights = listOf(100, 300, 600)
assertEquals(
25f,
estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights = weights,
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 500,
firstVisibleItemSize = 1000
),
0.001f
)
assertEquals(
40f,
estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights = weights,
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 1000,
firstVisibleItemSize = 1000
),
0.001f
)
}
@Test
fun `chapter page info uses chapter local locator offset`() {
val pageInfo = nativeVerticalChapterPageInfo(
chapterCharOffset = 500,
chapterLengthChars = 1000,
chapterPageCount = 11,
compatPageIndex = 900,
chapterStartPageIndex = 850
)
assertEquals(6, pageInfo?.currentPage)
assertEquals(11, pageInfo?.totalPages)
}
@Test
fun `chapter page info falls back to absolute page within chapter`() {
val pageInfo = nativeVerticalChapterPageInfo(
chapterCharOffset = null,
chapterLengthChars = 0,
chapterPageCount = 7,
compatPageIndex = 24,
chapterStartPageIndex = 20
)
assertEquals(5, pageInfo?.currentPage)
assertEquals(7, pageInfo?.totalPages)
}
@Test
fun `chapter page info follows scroll weight within current chapter`() {
val pageInfo = nativeVerticalChapterPageInfoForScroll(
itemChapterIndices = listOf(0, 0, 1, 1),
itemWeights = listOf(100, 300, 100, 300),
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 500,
firstVisibleItemSize = 1000,
chapterPageCount = 9
)
assertEquals(6, pageInfo?.currentPage)
assertEquals(9, pageInfo?.totalPages)
}
@Test
fun `native vertical image model decodes svg data uris for coil svg fetcher`() {
val model = nativeVerticalImageModelData(
"data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2010%2010%22%3E%3Ccircle%20r%3D%225%22%2F%3E%3C%2Fsvg%3E"
)
assertTrue(model is SvgData)
assertEquals("""<svg viewBox="0 0 10 10"><circle r="5"/></svg>""", (model as SvgData).content)
}
@Test
fun `native vertical svg data uri decoding preserves plus signs`() {
assertEquals(
"""<svg><path d="M1+2"/></svg>""",
nativeVerticalSvgContentFromDataUri(
"data:image/svg+xml,%3Csvg%3E%3Cpath%20d%3D%22M1+2%22%2F%3E%3C%2Fsvg%3E"
)
)
}
@Test
fun `native vertical persistence locator prefers visible text range`() {
val location = NativeVerticalLocation(
locator = Locator(chapterIndex = 2, blockIndex = 10, charOffset = 100),
chapterIndex = 2,
progressPercent = 42f,
compatPageIndex = 20,
compatTotalPages = 100,
firstVisibleItemIndex = 4,
firstVisibleItemScrollOffset = 250,
firstVisibleItemSize = 1000,
isAtStart = false,
isAtEnd = false,
visibleTextRanges = listOf(
NativeVerticalVisibleTextRange(
chapterIndex = 2,
blockIndex = 10,
startCharOffset = 380,
endCharOffset = 520
)
)
)
assertEquals(Locator(chapterIndex = 2, blockIndex = 10, charOffset = 380), location.locatorForPersistence())
}
@Test
fun `native vertical initial restore does not fallback to compat page when locator exists`() {
assertEquals(
false,
shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator = true,
didLocatorScroll = false
)
)
assertEquals(
true,
shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator = false,
didLocatorScroll = false
)
)
}
@Test
fun `native vertical tts follow centers target offset in viewport`() {
assertEquals(
100f,
nativeVerticalCenteredScrollDelta(
targetOffsetInViewport = 500f,
viewportHeight = 800f
),
0.001f
)
assertEquals(
-200f,
nativeVerticalCenteredScrollDelta(
targetOffsetInViewport = 200f,
viewportHeight = 800f
),
0.001f
)
}
}

View file

@ -76,7 +76,7 @@ class ReaderNavigationTargetsTest {
@Test
fun `native vertical initial prefetch is bounded around requested chapter`() {
assertEquals(
listOf(4, 5, 2),
listOf(4, 5),
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
)
}

View file

@ -111,6 +111,20 @@ class PdfReaderCoreLogicTest {
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
}
@Test
fun `pdf encrypt marker detection matches trailer encrypt entry`() {
val bytes = "%PDF-1.7\ntrailer\n<< /Size 4 /Encrypt 2 0 R >>".toByteArray(Charsets.US_ASCII)
assertTrue(pdfBytesContainEncryptMarker(bytes))
}
@Test
fun `pdf encrypt marker detection ignores longer pdf names`() {
val bytes = "<< /EncryptMetadata false /Size 4 >>".toByteArray(Charsets.US_ASCII)
assertFalse(pdfBytesContainEncryptMarker(bytes))
}
@Test
fun `getFastFileId uses stable file name and length for file uris`() {
val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply {
@ -383,6 +397,32 @@ class PdfReaderCoreLogicTest {
assertTrue(limitedScale >= 0.01f)
}
@Test
fun `spread page slot width fits page aspect instead of filling half landscape viewport`() {
val slotWidth = pdfSpreadPageSlotWidth(
containerWidth = 1920f,
containerHeight = 900f,
pageGap = 0f,
spreadPageCount = 2,
pageAspectRatio = 612f / 792f
)
assertEquals(695.4545f, slotWidth, 0.001f)
}
@Test
fun `spread page slot width caps pages to available spread width`() {
val slotWidth = pdfSpreadPageSlotWidth(
containerWidth = 1000f,
containerHeight = 900f,
pageGap = 20f,
spreadPageCount = 2,
pageAspectRatio = 1.4f
)
assertEquals(490f, slotWidth, 0.0001f)
}
@Test
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))

View file

@ -281,6 +281,23 @@ class PdfReaderSettingsAndSharedModelsTest {
assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last())
}
@Test
fun `pdf overflow sections hide file actions when only unavailable print remains`() {
val sections = pdfOverflowMenuSections(
hiddenTools = setOf(
PdfReaderTool.SHARE.name,
PdfReaderTool.SAVE_COPY.name
),
hasHiddenToolbarTools = false,
isPro = false,
effectiveFileType = FileType.PDF,
hasFileInfo = false,
canPrintDocument = false
)
assertFalse(PdfOverflowMenuSection.FILE_ACTIONS in sections)
}
@Test
fun `pdf overflow sections expose file info only when available and visible`() {
val visibleSections = pdfOverflowMenuSections(

View file

@ -96,6 +96,86 @@ class PdfZoomLockStateTest {
)
}
@Test
fun `vertical pdf high res tiles render for settled zoom below one hundred percent`() {
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 0.82f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
}
@Test
fun `vertical pdf high res tiles skip exact one hundred percent unless page is large`() {
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 1f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 1f,
targetWidthPx = 3200,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
}
@Test
fun `paginated pdf high res tiles keep existing zoom threshold`() {
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 0.82f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = true
)
)
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 1.25f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = true
)
)
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 1.25f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = false
)
)
}
@Test
fun `zoom indicator percent rounds displayed scale`() {
assertEquals(82, pdfZoomIndicatorPercent(0.824f))
assertEquals(83, pdfZoomIndicatorPercent(0.826f))
assertEquals(100, pdfZoomIndicatorPercent(0.996f))
}
@Test
fun `zoom indicator hides only at displayed one hundred percent`() {
assertFalse(shouldShowPdfZoomIndicator(100))
assertTrue(shouldShowPdfZoomIndicator(99))
assertTrue(shouldShowPdfZoomIndicator(125))
}
@Test
fun `page change preserves locked zoom scale only in paginated lock mode`() {
val lockedState = Triple(2.25f, -12f, 32f)

View file

@ -7,6 +7,7 @@ import org.junit.Test
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
class TtsChunkNavigationTest {
@Test
@ -53,6 +54,25 @@ class TtsChunkNavigationTest {
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null))
}
@Test
fun `automatic playlist advance can step over chunks marked skipped after generation failures`() {
assertEquals(
true,
shouldAdvanceToTtsPlaylistChunk(
currentChunkIndex = 8,
playlistChunkIndex = 10,
skippedChunkIndices = setOf(9)
)
)
assertEquals(10, resolveNextPlayableTtsChunkIndex(8, 12, setOf(9)))
}
@Test
fun `chunk generation gives up after bounded failures`() {
assertEquals(false, shouldGiveUpTtsChunkGeneration(failureCount = 1, maxFailures = 2))
assertEquals(true, shouldGiveUpTtsChunkGeneration(failureCount = 2, maxFailures = 2))
}
@Test
fun `transition prefetch is deferred only for the rebuilding generation`() {
assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6))
@ -150,6 +170,16 @@ class TtsChunkNavigationTest {
assertNull(estimateTtsNotificationDurationMs(text = " "))
}
@Test
fun `stable sorted snapshot copies concurrent cache keys`() {
val cache = ConcurrentHashMap<Int, String>()
cache[3] = "three"
cache[1] = "one"
cache[2] = "two"
assertEquals(listOf(1, 2, 3), stableSortedIntSnapshot(cache.keys))
}
@Test
fun `wav file duration is read from pcm byte rate`() {
val file = createTempWavFile(pcmBytes = 48_000)