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

@ -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()
}
}