Bug and crash fixes (#132)

* feat(EPUB): adapt status bar icon visibility to reader theme

Pass the reader's active isDarkTheme state to EpubReaderSystemUiController to ensure status bar icons automatically switch between light and dark to remain visible against the selected background/page color.

* Implement storage tracking and optimize cache management

This commit introduces a `StorageTracker` to monitor disk usage and implements several optimizations to prevent cache bloat and handle orphaned files.

* refactor: unify book extraction paths and fix cache leaks

- Replaced random UUID and title-based hashing with stable `bookId` for extraction directories across Epub, Mobi, and FB2 parsers.
- Standardized all temporary extraction paths to `cache/imported_file_${bookId}`.
- Updated MainViewModel and MetadataExtractionWorker to provide the mandatory `bookId` during parsing.
- Ensured `clearImportedFileCache` successfully targets the correct directories on book deletion.
- Added a legacy cleanup task in `sweepOrphanedCache` to reclaim storage from old `extracted_epubs` folders.

* fix(pdf): handle NoClassDefFoundError for PdfPasswordException

Updated the PDF loading logic in PdfViewerScreen to catch Throwable
instead of Exception. This prevents the app from crashing when the
underlying pdfium library attempts to throw a PdfPasswordException
that is missing from the runtime classpath (NoClassDefFoundError).

* fix(tts): set language before voice to prevent variant reset

* EpubReaderScreen: increase scroll-hide ignore duration for tap toggles

Increase the threshold for ignoring tap toggles after bars are hidden by scrolling from 250ms to 400ms. This prevents accidental UI toggles caused by "sloppy taps" immediately following a scroll action.

* fix: resolve large bitmap crash on tall PDF pages

- Caps base layer bitmap dimensions to 3000px to prevent GPU texture limit crashes.
- Enables tiled rendering for large pages even at 1x zoom to maintain sharpness.
- Optimizes PdfBitmapPool to support rectangular bitmap allocations.

* Common.kt: ensure unique keys in voice list

* fix: handle ActivityNotFoundException in folder sync screen

* Improve file deletion logic and fix chapter index bounds in epub reader

* fix: ensure WebView bridge callbacks run on UI thread

This resolves a WebViewMethodCalledOnWrongThreadViolation in vertical mode that was preventing chapter chunks from loading beyond the initial viewport.
This commit is contained in:
Aryan 2026-03-30 21:19:27 +05:30 committed by GitHub
parent 0d37bcefc3
commit 355664fbcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 502 additions and 280 deletions

View file

@ -1661,7 +1661,7 @@ fun DeviceVoiceSettingsSheet(
) {
items(
filteredVoices.size,
key = { filteredVoices[it].name }) { index ->
key = { "${filteredVoices[it].name}_$it" }) { index ->
val voice = filteredVoices[index]
val isSelected = voice.name == savedVoiceName
val friendlyName = numberedVoiceNames[voice.name]
@ -1692,6 +1692,11 @@ fun DeviceVoiceSettingsSheet(
trailingContent = {
IconButton(onClick = {
val params = android.os.Bundle()
try {
ttsEngine?.language = voice.locale
} catch (e: Exception) {
Timber.e(e, "Failed to set language for sample")
}
ttsEngine?.voice = voice
val sampleText =
"This is a sample of ${voice.locale.displayLanguage}."

View file

@ -275,7 +275,6 @@ fun LibraryScreen(
onSelectSyncFolderClick = onSelectSyncFolderClick,
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders,
onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) },
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds,
@ -502,7 +501,6 @@ fun LibraryScreenContent(
isLoading: Boolean,
isRefreshing: Boolean,
syncedFolders: List<SyncedFolder>,
onAddFolderClick: (android.net.Uri) -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
) {
val isBookContextualModeActive = selectedItems.isNotEmpty()
@ -763,7 +761,7 @@ fun LibraryScreenContent(
FolderSyncScreen(
syncedFolders = syncedFolders,
allRecentFiles = rawLibraryFiles,
onAddFolderClick = onAddFolderClick,
onAddFolderClick = onSelectSyncFolderClick,
onRemoveFolderClick = onRemoveFolderClick,
onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick,
@ -1473,7 +1471,7 @@ private fun DeleteShelvesConfirmationDialog(
private fun FolderSyncScreen(
syncedFolders: List<SyncedFolder>,
allRecentFiles: List<RecentFileItem>,
onAddFolderClick: (android.net.Uri) -> Unit,
onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit,
@ -1482,21 +1480,13 @@ private fun FolderSyncScreen(
) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
onAddFolderClick(it)
}
}
Scaffold(
floatingActionButton = {
if (syncedFolders.size < 3) {
ExtendedFloatingActionButton(
text = { Text("Add Folder") },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = { pickFolderLauncher.launch(null) }
onClick = onAddFolderClick
)
}
}
@ -1543,7 +1533,7 @@ private fun FolderSyncScreen(
EmptyState(
title = "Sync Local Folders",
message = "Connect local folders to create a live library. Episteme will monitor files and sync progress.",
onSelectFileClick = { pickFolderLauncher.launch(null) },
onSelectFileClick = onAddFolderClick,
primaryButtonText = "Select Folder",
modifier = Modifier.fillMaxSize()
)

View file

@ -35,6 +35,7 @@ import android.provider.OpenableColumns
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.credentials.exceptions.GetCredentialCancellationException
import kotlinx.coroutines.withTimeoutOrNull
import androidx.credentials.exceptions.NoCredentialException
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
@ -607,6 +608,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
syncFolderMetadata()
}
sweepOrphanedCache()
viewModelScope.launch { billingClientWrapper.initializeConnection() }
viewModelScope.launch {
@ -1511,7 +1514,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
saveSyncedFoldersToPrefs(currentFolders)
_internalState.update { it.copy(syncedFolders = currentFolders) }
// Cleanup
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { pdfTextRepository.clearBookText(it.bookId) }
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
try {
appContext.contentResolver.releasePersistableUriPermission(
@ -1638,6 +1643,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch {
val folders = _internalState.value.syncedFolders
folders.forEach { folder ->
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { pdfTextRepository.clearBookText(it.bookId) }
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
try {
appContext.contentResolver.releasePersistableUriPermission(
@ -1912,7 +1920,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (currentUser != null) {
val deviceId = getInstallationId()
try {
firestoreRepository.deleteDevice(currentUser.uid, deviceId)
withTimeoutOrNull(3000) {
firestoreRepository.deleteDevice(currentUser.uid, deviceId)
}
Timber.i("Device $deviceId unregistered on sign out.")
} catch (e: Exception) {
Timber.e(e, "Failed to unregister device on sign out.")
@ -2345,31 +2355,34 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
FileType.EPUB -> {
epubParser.createEpubBook(
inputStream = inputStream,
bookId = bookId,
originalBookNameHint = displayName,
parseContent = false
)
}
FileType.MOBI -> {
mobiParser.createMobiBook(
inputStream = inputStream,
originalBookNameHint = displayName
bookId = bookId,
originalBookNameHint = displayName,
parseContent = false
)
}
FileType.FB2 -> {
fb2Parser.createFb2Book(
inputStream = inputStream,
originalBookNameHint = displayName
bookId = bookId,
originalBookNameHint = displayName,
parseContent = false
)
}
else -> {
singleFileImporter.importSingleFile(
inputStream,
type,
originalBookNameHint = displayName,
bookId = bookId
bookId = bookId,
parseContent = false
)
}
}
@ -2412,8 +2425,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
}
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
var cacheFile: File? = null
try {
val cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}")
cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}")
withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { input ->
cacheFile.outputStream().use { output -> input.copyTo(output) }
@ -2440,6 +2454,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
archiveDoc.close()
} catch (e: Exception) {
Timber.e(e, "Error generating CBZ cover")
} finally {
try {
if (cacheFile?.exists() == true) {
val deleted = cacheFile.delete()
if (deleted) Timber.d("Successfully deleted temp archive file: ${cacheFile.name}")
}
} catch (e: Exception) {
Timber.e(e, "Failed to delete temp archive file")
}
}
}
}
@ -2492,6 +2515,42 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(errorMessage = null) }
}
private fun sweepOrphanedCache() {
viewModelScope.launch(Dispatchers.IO) {
Timber.d("Running Cache Sweeper to clean up orphaned temporary files...")
try {
val cacheDir = appContext.cacheDir
if (!cacheDir.exists()) return@launch
val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
cacheDir.listFiles()?.forEach { file ->
val name = file.name
if (name.startsWith("temp_") || name.startsWith("sync_bundle_")) {
if (file.lastModified() < oneHourAgo) {
val deleted = if (file.isDirectory) file.deleteRecursively() else file.delete()
if (deleted) Timber.d("Sweeper cleaned old temp file: $name")
}
} else if (name.startsWith("imported_file_")) {
val bookId = name.removePrefix("imported_file_")
if (bookId !in allDbIds) {
val deleted = file.deleteRecursively()
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId")
}
}
}
val legacyExtractedDir = File(cacheDir, "extracted_epubs")
if (legacyExtractedDir.exists()) {
val deleted = legacyExtractedDir.deleteRecursively()
if (deleted) Timber.d("Sweeper reclaimed storage by deleting legacy extracted_epubs directory")
}
} catch (e: Exception) {
Timber.e(e, "Error during cache sweep: ${e.message}")
}
}
}
private fun getFileNameFromUri(uri: Uri, context: Context): String? {
var fileName: String? = null
if (uri.scheme == "content") {
@ -2906,7 +2965,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
appContext.contentResolver.openInputStream(uri).use { inputStream ->
if (inputStream == null) throw Exception("Could not open input stream")
fb2Parser.createFb2Book(
inputStream,
inputStream = inputStream,
bookId = bookId,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.fb2"
)
}
@ -3086,10 +3146,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
throw Exception("Could not open input stream for URI")
}
mobiParser.createMobiBook(
inputStream,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(
uri, appContext
) ?: "unknown.mobi"
inputStream = inputStream,
bookId = bookId,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.mobi"
)
}
}
@ -3137,10 +3196,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
throw Exception("Could not open input stream for URI")
}
epubParser.createEpubBook(
inputStream,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(
uri, appContext
) ?: "unknown.epub"
inputStream = inputStream,
bookId = bookId,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.epub"
)
}
}
@ -3725,126 +3783,125 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null }
if (folderBooks.isNotEmpty()) {
Timber.d("Processing ${folderBooks.size} folder books for deletion.")
withContext(Dispatchers.IO) {
if (folderBooks.isNotEmpty()) {
Timber.d("Processing ${folderBooks.size} folder books for deletion.")
val idsToDeleteLocally = mutableListOf<String>()
val idsToDeleteLocally = mutableListOf<String>()
folderBooks.forEach { item ->
idsToDeleteLocally.add(item.bookId)
pdfTextRepository.clearBookText(item.bookId)
folderBooks.forEach { item ->
idsToDeleteLocally.add(item.bookId)
pdfTextRepository.clearBookText(item.bookId)
clearImportedFileCache(item.bookId)
clearImportedFileCache(item.bookId)
if (item.uriString != null) {
try {
val fileUri = item.uriString.toUri()
val fileDoc = DocumentFile.fromSingleUri(appContext, fileUri)
if (fileDoc != null && fileDoc.exists()) {
if (fileDoc.delete()) {
Timber.i("Physically deleted folder file: ${item.displayName}")
} else {
Timber.e("Failed to delete folder file via SAF: ${item.displayName}")
if (item.uriString != null) {
try {
val fileUri = item.uriString.toUri()
val fileDoc = DocumentFile.fromSingleUri(appContext, fileUri)
if (fileDoc != null && fileDoc.exists()) {
if (fileDoc.delete()) {
Timber.i("Physically deleted folder file: ${item.displayName}")
} else {
Timber.e("Failed to delete folder file via SAF: ${item.displayName}")
}
}
} catch (e: Exception) {
Timber.e(e, "Error deleting physical file for ${item.bookId}")
}
}
if (item.sourceFolderUri != null) {
try {
val rootUri = item.sourceFolderUri.toUri()
val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri)
if (rootDoc != null) {
val hiddenMeta = rootDoc.findFile(".${item.bookId}.json")
val legacyVisibleMeta = rootDoc.findFile("${item.bookId}.json")
hiddenMeta?.delete()
legacyVisibleMeta?.delete()
Timber.tag("FolderSync")
.d("Deleted metadata for ${item.bookId} from root.")
}
} catch (e: Exception) {
Timber.e(e, "Error deleting metadata file for ${item.bookId}")
}
} catch (e: Exception) {
Timber.e(e, "Error deleting physical file for ${item.bookId}")
}
}
// 2. Try to delete the metadata JSON (.bookId.json)
if (item.sourceFolderUri != null) {
try {
val rootUri = item.sourceFolderUri.toUri()
val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri)
if (rootDoc != null) {
val hiddenMeta = rootDoc.findFile(".${item.bookId}.json")
val legacyVisibleMeta = rootDoc.findFile("${item.bookId}.json")
hiddenMeta?.delete()
legacyVisibleMeta?.delete()
Timber.tag("FolderSync")
.d("Deleted metadata for ${item.bookId} from root.")
}
} catch (e: Exception) {
Timber.e(e, "Error deleting metadata file for ${item.bookId}")
}
}
recentFilesRepository.deleteFilePermanently(idsToDeleteLocally)
}
recentFilesRepository.deleteFilePermanently(idsToDeleteLocally)
}
if (managedBooks.isNotEmpty()) {
val currentUser = uiState.value.currentUser
if (canSync && currentUser != null) {
_internalState.update {
it.copy(
isLoading = true,
bannerMessage = BannerMessage("Deleting from all devices...")
)
}
try {
val accessToken =
googleDriveRepository.getAccessToken(appContext) ?: throw Exception(
"No token"
)
val deviceId = getInstallationId()
val remoteFiles = withContext(Dispatchers.IO) {
googleDriveRepository.getFiles(accessToken)?.files.orEmpty()
.associateBy { it.name }
}
for (item in managedBooks) {
recentFilesRepository.markAsDeleted(listOf(item.bookId))
pdfTextRepository.clearBookText(item.bookId)
clearImportedFileCache(item.bookId)
firestoreRepository.syncBookMetadata(
currentUser.uid,
item.toBookMetadata().copy(isDeleted = true),
deviceId
)
val fileExtension = item.type.name.lowercase()
val fileName = "${item.bookId}.$fileExtension"
remoteFiles[fileName]?.id?.let { fileId ->
Timber.d("Deleting from Drive: $fileName")
googleDriveRepository.deleteDriveFile(accessToken, fileId)
}
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
}
if (managedBooks.isNotEmpty()) {
val currentUser = uiState.value.currentUser
if (canSync && currentUser != null) {
_internalState.update {
it.copy(
isLoading = false,
bannerMessage = BannerMessage("Deletion complete.")
isLoading = true,
bannerMessage = BannerMessage("Deleting from all devices...")
)
}
} catch (e: Exception) {
Timber.e(e, "Error during permanent deletion")
try {
val accessToken =
googleDriveRepository.getAccessToken(appContext) ?: throw Exception(
"No token"
)
val deviceId = getInstallationId()
val remoteFiles = googleDriveRepository.getFiles(accessToken)?.files.orEmpty()
.associateBy { it.name }
for (item in managedBooks) {
recentFilesRepository.markAsDeleted(listOf(item.bookId))
pdfTextRepository.clearBookText(item.bookId)
clearImportedFileCache(item.bookId)
firestoreRepository.syncBookMetadata(
currentUser.uid,
item.toBookMetadata().copy(isDeleted = true),
deviceId
)
val fileExtension = item.type.name.lowercase()
val fileName = "${item.bookId}.$fileExtension"
remoteFiles[fileName]?.id?.let { fileId ->
Timber.d("Deleting from Drive: $fileName")
googleDriveRepository.deleteDriveFile(accessToken, fileId)
}
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
}
_internalState.update {
it.copy(
isLoading = false,
bannerMessage = BannerMessage("Deletion complete.")
)
}
} catch (e: Exception) {
Timber.e(e, "Error during permanent deletion")
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
managedBooks.forEach { item ->
clearImportedFileCache(item.bookId)
pdfTextRepository.clearBookText(item.bookId)
}
_internalState.update {
it.copy(
isLoading = false,
errorMessage = "Cloud sync failed, deleted locally."
)
}
}
} else {
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
managedBooks.forEach { item ->
clearImportedFileCache(item.bookId)
pdfTextRepository.clearBookText(item.bookId)
}
_internalState.update {
it.copy(
isLoading = false,
errorMessage = "Cloud sync failed, deleted locally."
)
}
}
} else {
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
managedBooks.forEach { item ->
clearImportedFileCache(item.bookId)
pdfTextRepository.clearBookText(item.bookId)
}
}
}

View file

@ -12,6 +12,7 @@ import com.aryan.reader.pdf.PdfCoverGenerator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
class MetadataExtractionWorker(
private val appContext: Context,
@ -65,6 +66,7 @@ class MetadataExtractionWorker(
FileType.EPUB -> {
val book = epubParser.createEpubBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false
)
@ -73,7 +75,12 @@ class MetadataExtractionWorker(
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
}
FileType.MOBI -> {
val book = mobiParser.createMobiBook(inputStream, item.displayName)
val book = mobiParser.createMobiBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false
)
book?.let {
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) }
@ -105,6 +112,16 @@ class MetadataExtractionWorker(
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}")
} finally {
try {
val cacheDir = File(appContext.cacheDir, "imported_file_${item.bookId}")
if (cacheDir.exists()) {
val deleted = cacheDir.deleteRecursively()
if (deleted) Timber.tag("MetadataWorker").d("Cleaned up extraction cache for ${item.bookId}")
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to clean up extraction cache for ${item.bookId}")
}
}
}

View file

@ -0,0 +1,50 @@
// StorageTracker.kt
package com.aryan.reader
import android.content.Context
import timber.log.Timber
import java.io.File
import kotlin.math.log10
import kotlin.math.pow
object StorageTracker {
fun logStorageUsage(context: Context, tag: String) {
try {
val cacheSize = getDirSize(context.cacheDir)
val filesSize = getDirSize(context.filesDir)
val dbDir = File(context.applicationInfo.dataDir, "databases")
val dbSize = getDirSize(dbDir)
Timber.tag("StorageTracker").i("[$tag] Cache: ${formatSize(cacheSize)} | Files: ${formatSize(filesSize)} | DBs: ${formatSize(dbSize)}")
// Log top 3 largest items in cache to pinpoint bloat
val largestCacheFiles = context.cacheDir.listFiles()
?.sortedByDescending { getDirSize(it) }
?.take(3)
?.joinToString { "${it.name}: ${formatSize(getDirSize(it))}" }
if (!largestCacheFiles.isNullOrEmpty()) {
Timber.tag("StorageTracker").d("Largest in Cache -> $largestCacheFiles")
}
} catch (e: Exception) {
Timber.tag("StorageTracker").e(e, "Error calculating storage")
}
}
private fun getDirSize(dir: File?): Long {
if (dir == null || !dir.exists()) return 0
if (dir.isFile) return dir.length()
var size: Long = 0
dir.listFiles()?.forEach { file ->
size += getDirSize(file)
}
return size
}
private fun formatSize(size: Long): String {
if (size <= 0) return "0 B"
val units = arrayOf("B", "KB", "MB", "GB", "TB")
val digitGroups = (log10(size.toDouble()) / log10(1024.0)).toInt()
return String.format("%.2f %s", size / 1024.0.pow(digitGroups.toDouble()), units[digitGroups])
}
}

View file

@ -89,7 +89,19 @@ class RecentFilesRepository(private val context: Context) {
}
coverCacheDir.mkdirs()
pdfHighlightRepository.clearAll()
Timber.d("Cleared all local book data and cover cache.")
File(context.filesDir, "annotations").deleteRecursively()
File(context.filesDir, "pdf_rich_text").deleteRecursively()
File(context.filesDir, "page_layouts").deleteRecursively()
File(context.filesDir, "pdf_text_boxes").deleteRecursively()
context.cacheDir.listFiles()?.forEach { file ->
val name = file.name
if (name.startsWith("imported_file_") || name.startsWith("temp_") || name.startsWith("sync_bundle_")) {
if (file.isDirectory) file.deleteRecursively() else file.delete()
}
}
Timber.d("Cleared all local book data, sidecars, and cover cache.")
}
suspend fun addRecentFile(item: RecentFileItem) = withContext(Dispatchers.IO) {
@ -289,7 +301,13 @@ class RecentFilesRepository(private val context: Context) {
}
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
recentFileDao.deleteFilesBySourceFolder(folderUriString)
val filesToRemove = getFilesBySourceFolder(folderUriString)
if (filesToRemove.isNotEmpty()) {
Timber.d("DeleteDebug: Cascading deletion for ${filesToRemove.size} files from folder.")
deleteFilePermanently(filesToRemove.map { it.bookId })
} else {
recentFileDao.deleteFilesBySourceFolder(folderUriString)
}
}
suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) {
@ -368,6 +386,19 @@ class RecentFilesRepository(private val context: Context) {
} catch (e: Exception) {
Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}")
}
try {
pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete()
pdfRichTextRepository.getFileForSync(item.bookId).delete()
pageLayoutRepository.getLayoutFile(item.bookId).delete()
pdfTextBoxRepository.getFileForSync(item.bookId).delete()
pdfHighlightRepository.getFileForSync(item.bookId).delete()
val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}")
if (cacheDir.exists()) cacheDir.deleteRecursively()
} catch (e: Exception) {
Timber.e(e, "Error during deep cleanup of sidecars for ${item.bookId}: ${e.message}")
}
}
recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId })
Timber.d("Permanently removed recent files from DB.")

View file

@ -92,7 +92,6 @@ class EpubParser(private val context: Context) {
companion object {
const val TAG = "EpubParser"
internal const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs"
}
internal val String.decodedURL: String
@ -103,15 +102,6 @@ class EpubParser(private val context: Context) {
this
}
private fun getBookExtractionDir(bookIdentifier: String): File {
val parentDir = File(context.cacheDir, EXTRACTED_EPUB_DIR_NAME)
if (!parentDir.exists()) {
parentDir.mkdirs()
}
return File(parentDir, bookIdentifier)
}
private fun parsePageList(pageListElement: Element?, ncxFileParentDir: File): List<EpubPageTarget> {
if (pageListElement == null) {
Timber.d("No <pageList> element found in NCX.")
@ -174,15 +164,15 @@ class EpubParser(private val context: Context) {
suspend fun createEpubBook(
inputStream: InputStream,
bookId: String,
shouldUseToc: Boolean = true,
originalBookNameHint: String = "streamed_book",
parseContent: Boolean = true
): EpubBook {
return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream")
Timber.d("Parsing EPUB input stream for bookId: $bookId")
val bookIdentifier = originalBookNameHint.asFileName() + "_" + UUID.randomUUID().toString().substring(0, 8)
val extractionDir = getBookExtractionDir(bookIdentifier)
val extractionDir = File(context.cacheDir, "imported_file_$bookId")
if (extractionDir.exists()) {
extractionDir.deleteRecursively()
@ -202,7 +192,7 @@ class EpubParser(private val context: Context) {
val document = createEpubDocument(filesMap)
val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath,
bookIdentifier, parseContent)
originalBookNameHint, parseContent)
return@withContext book
}
}
@ -211,6 +201,17 @@ class EpubParser(private val context: Context) {
val filesMap = mutableMapOf<String, EpubFile>()
zipFile.use { zf ->
zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry ->
val isEssential = isEssentialFile(entry.name, parseContent)
val isImage = entry.name.matches(Regex(".*\\.(png|jpg|jpeg|gif|webp|svg)$", RegexOption.IGNORE_CASE))
if (!parseContent) {
if (isEssential || isImage) {
val data = zf.getInputStream(entry).readBytes()
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
}
return@forEach
}
val outputFile = File(extractionDir, entry.name)
outputFile.parentFile?.mkdirs()
zf.getInputStream(entry).use { input ->
@ -219,12 +220,7 @@ class EpubParser(private val context: Context) {
}
}
val data = if (isEssentialFile(entry.name, parseContent)) {
outputFile.readBytes()
} else {
ByteArray(0)
}
val data = if (isEssential) outputFile.readBytes() else ByteArray(0)
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
}
}

View file

@ -18,14 +18,14 @@ class Fb2Parser(private val context: Context) {
suspend fun createFb2Book(
inputStream: InputStream,
originalBookNameHint: String
bookId: String,
originalBookNameHint: String,
parseContent: Boolean = true
): EpubBook {
val bookId = originalBookNameHint.hashCode().toString()
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
// Seamless ZIP extraction for .fb2.zip extensions
var streamToParse = inputStream
if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) {
val zis = ZipInputStream(inputStream)
@ -74,7 +74,7 @@ class Fb2Parser(private val context: Context) {
""".trimIndent()
fun saveChapter() {
if (currentChapterHtml.isEmpty()) return
if (!parseContent || currentChapterHtml.isEmpty()) return
chapterCount++
val fileName = "chapter_$chapterCount.html"
val file = File(extractionDir, fileName)
@ -182,12 +182,13 @@ class Fb2Parser(private val context: Context) {
val base64Data = parser.nextText()
try {
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
val imgFile = File(extractionDir, id)
withContext(Dispatchers.IO) {
FileOutputStream(imgFile).use { it.write(bytes) }
if (parseContent) {
val imgFile = File(extractionDir, id)
withContext(Dispatchers.IO) {
FileOutputStream(imgFile).use { it.write(bytes) }
}
}
// Add the image to the EpubBook image index
images.add(EpubImage(absPath = id))
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
@ -245,9 +246,9 @@ class Fb2Parser(private val context: Context) {
}
}
saveChapter() // Save the final chunk of content
saveChapter()
if (chapters.isEmpty()) {
if (chapters.isEmpty() && parseContent) {
if (currentChapterHtml.isNotBlank()) {
saveChapter()
} else {

View file

@ -125,7 +125,12 @@ class MobiParser(private val context: Context) {
return File(parentDir, bookIdentifier)
}
suspend fun createMobiBook(inputStream: InputStream, originalBookNameHint: String): EpubBook? = withContext(Dispatchers.IO) {
suspend fun createMobiBook(
inputStream: InputStream,
bookId: String,
originalBookNameHint: String,
parseContent: Boolean = true
): EpubBook? = withContext(Dispatchers.IO) {
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
try {
tempFile.outputStream().use { output ->
@ -157,25 +162,25 @@ class MobiParser(private val context: Context) {
val bookTitle = parsedData.title ?: originalBookNameHint
val bookAuthor = parsedData.author ?: "Unknown Author"
val bookIdentifier = bookTitle.asFileName() + "_" + UUID.randomUUID().toString().substring(0, 8)
val extractionDir = getBookExtractionDir(bookIdentifier)
val extractionDir = File(context.cacheDir, "imported_file_$bookId")
extractionDir.mkdirs()
// This map is the key. It maps the 1-based sequential index of an image to its new path.
val sequentialImageMap = parsedData.resources
.filter { it.mediaType.startsWith("image/") }
.sortedBy { it.uid } // Sort by UID to ensure order is correct
.sortedBy { it.uid }
.mapIndexed { index, resource -> (index + 1) to resource.path }
.toMap()
parsedData.resources.forEach { resource ->
try {
val file = File(extractionDir, resource.path)
file.parentFile?.mkdirs()
file.writeBytes(resource.data)
Timber.d("Wrote resource to disk -> Path: ${file.absolutePath}, Type: ${resource.mediaType}, Size: ${resource.data.size}")
} catch (e: Exception) {
Timber.e(e, "Parser: FAILED to write resource to disk: ${resource.path}")
if (parseContent) {
parsedData.resources.forEach { resource ->
try {
val file = File(extractionDir, resource.path)
file.parentFile?.mkdirs()
file.writeBytes(resource.data)
Timber.d("Wrote resource to disk -> Path: ${file.absolutePath}")
} catch (e: Exception) {
Timber.e(e, "Parser: FAILED to write resource to disk: ${resource.path}")
}
}
}
@ -250,26 +255,28 @@ class MobiParser(private val context: Context) {
Timber.d("Successfully split content into ${chapterHtmlParts.size} chapters.")
val epubChapters = chapterHtmlParts.mapIndexedNotNull { index, (chapterHtml, title) ->
try {
val rewrittenHtml = processChapterHtml(chapterHtml)
val doc = Jsoup.parse(rewrittenHtml)
val chapterFileName = "chapter_$index.html"
val chapterFile = File(extractionDir, chapterFileName)
chapterFile.writeText(rewrittenHtml)
EpubChapter(
chapterId = "mobi_chapter_$index",
title = title,
absPath = chapterFileName,
htmlFilePath = chapterFileName,
htmlContent = rewrittenHtml,
plainTextContent = doc.text()
)
} catch (e: Exception) {
Timber.e(e, "Failed to process split chapter $index")
null
val epubChapters = if (parseContent) {
chapterHtmlParts.mapIndexedNotNull { index, (chapterHtml, title) ->
try {
val rewrittenHtml = processChapterHtml(chapterHtml)
val doc = Jsoup.parse(rewrittenHtml)
val chapterFileName = "chapter_$index.html"
val chapterFile = File(extractionDir, chapterFileName)
chapterFile.writeText(rewrittenHtml)
EpubChapter(
chapterId = "mobi_chapter_$index",
title = title,
absPath = chapterFileName,
htmlFilePath = chapterFileName,
htmlContent = rewrittenHtml,
plainTextContent = doc.text()
)
} catch (e: Exception) {
Timber.e(e, "Failed to process split chapter $index")
null
}
}
}
} else emptyList()
val images = parsedData.resources
.filter { it.mediaType.startsWith("image/") }

View file

@ -48,21 +48,38 @@ class SingleFileImporter(private val context: Context) {
inputStream: InputStream,
type: FileType,
originalBookNameHint: String,
bookId: String
bookId: String,
parseContent: Boolean = true
): EpubBook {
return when (type) {
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId)
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId)
FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId)
else -> parsePlainText(inputStream, originalBookNameHint, bookId) // Fallback
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId, parseContent)
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId, parseContent)
else -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
}
}
private suspend fun parseMarkdown(
inputStream: InputStream,
originalBookNameHint: String,
bookId: String
bookId: String,
parseContent: Boolean
): EpubBook = withContext(Dispatchers.IO) {
if (!parseContent) {
return@withContext EpubBook(
fileName = originalBookNameHint,
title = originalBookNameHint.substringBeforeLast("."),
author = "Unknown",
language = "en",
coverImage = null,
chapters = emptyList(),
chaptersForPagination = emptyList(),
images = emptyList(),
pageList = emptyList(),
extractionBasePath = "",
css = emptyMap()
)
}
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
@ -191,8 +208,24 @@ class SingleFileImporter(private val context: Context) {
private suspend fun parsePlainText(
inputStream: InputStream,
originalBookNameHint: String,
bookId: String
bookId: String,
parseContent: Boolean
): EpubBook = withContext(Dispatchers.IO) {
if (!parseContent) {
return@withContext EpubBook(
fileName = originalBookNameHint,
title = originalBookNameHint.substringBeforeLast("."),
author = "Unknown",
language = "en",
coverImage = null,
chapters = emptyList(),
chaptersForPagination = emptyList(),
images = emptyList(),
pageList = emptyList(),
extractionBasePath = "",
css = emptyMap()
)
}
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
@ -347,8 +380,24 @@ class SingleFileImporter(private val context: Context) {
private suspend fun parseHtml(
inputStream: InputStream,
originalBookNameHint: String,
bookId: String
bookId: String,
parseContent: Boolean
): EpubBook = withContext(Dispatchers.IO) {
if (!parseContent) {
return@withContext EpubBook(
fileName = originalBookNameHint,
title = originalBookNameHint.substringBeforeLast("."),
author = "Unknown",
language = "en",
coverImage = null,
chapters = emptyList(),
chaptersForPagination = emptyList(),
images = emptyList(),
pageList = emptyList(),
extractionBasePath = "",
css = emptyMap()
)
}
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}

View file

@ -490,50 +490,61 @@ fun ChapterWebView(
localWebViewRef = this
onWebViewInstanceCreated(this)
addJavascriptInterface(
PageInfoBridge(onScrollStateUpdate), "PageInfoReporter"
PageInfoBridge { scrollY, scrollHeight, clientHeight, activeFragmentId ->
this.post { onScrollStateUpdate(scrollY, scrollHeight, clientHeight, activeFragmentId) }
}, "PageInfoReporter"
)
addJavascriptInterface(
ProgressJsBridge(onTopChunkUpdated), "ProgressReporter"
ProgressJsBridge { chunkIndex ->
this.post { onTopChunkUpdated(chunkIndex) }
}, "ProgressReporter"
)
addJavascriptInterface(ContentBridge(onChunkRequested), "ContentBridge")
addJavascriptInterface(ContentBridge { index ->
this.post { onChunkRequested(index) }
}, "ContentBridge")
addJavascriptInterface(
HighlightJsBridge(
onCreateCallback = onHighlightCreated,
onClickCallback = { cfi, text, left, top, right, bottom ->
onCreateCallback = { cfi, text, colorId ->
this.post { onHighlightCreated(cfi, text, colorId) }
},
onClickCallback = { cfi, text, left, top, right, bottom ->
this.post {
onHighlightClicked()
onHighlightClicked()
val densityValue = density.density
val locationOnScreen = IntArray(2)
this.getLocationOnScreen(locationOnScreen)
val xOffset = locationOnScreen[0]
val yOffset = locationOnScreen[1]
val densityValue = density.density
val locationOnScreen = IntArray(2)
this.getLocationOnScreen(locationOnScreen)
val xOffset = locationOnScreen[0]
val yOffset = locationOnScreen[1]
val rect = Rect(
(left * densityValue).toInt() + xOffset,
(top * densityValue).toInt() + yOffset,
(right * densityValue).toInt() + xOffset,
(bottom * densityValue).toInt() + yOffset
)
customMenuState = CustomMenuState(
selectedText = text,
selectionBounds = rect,
finishActionModeCallback = {
localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
val rect = Rect(
(left * densityValue).toInt() + xOffset,
(top * densityValue).toInt() + yOffset,
(right * densityValue).toInt() + xOffset,
(bottom * densityValue).toInt() + yOffset
)
},
cfi = cfi,
isExistingHighlight = true
)
}), "HighlightBridge")
customMenuState = CustomMenuState(
selectedText = text,
selectionBounds = rect,
finishActionModeCallback = {
localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
)
},
cfi = cfi,
isExistingHighlight = true
)
}
}
), "HighlightBridge"
)
addJavascriptInterface(
AutoScrollJsBridge {
onAutoScrollChapterEnd()
this.post { onAutoScrollChapterEnd() }
}, "AutoScrollBridge"
)
@ -602,15 +613,17 @@ fun ChapterWebView(
}
addJavascriptInterface(
CfiJsBridge(
onCfiReady = { cfi -> currentOnCfiGenerated(cfi) },
onCfiForBookmarkReady = { cfi -> currentOnBookmarkCfiGenerated(cfi) },
onScrollFinishedCallback = { success ->
currentOnScrollFinished(success)
}), "CfiBridge")
onCfiReady = { cfi -> this.post { currentOnCfiGenerated(cfi) } },
onCfiForBookmarkReady = { cfi -> this.post { currentOnBookmarkCfiGenerated(cfi) } },
onScrollFinishedCallback = { success ->
this.post { currentOnScrollFinished(success) }
}
), "CfiBridge"
)
addJavascriptInterface(
SnippetJsBridge { cfi, snippet ->
currentOnSnippetForBookmarkReady(cfi, snippet)
this.post { currentOnSnippetForBookmarkReady(cfi, snippet) }
}, "SnippetBridge"
)
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")

View file

@ -779,7 +779,7 @@ fun EpubReaderHost(
var currentChapterIndex by rememberSaveable(epubBook.title) {
mutableIntStateOf(
initialLocator?.chapterIndex?.coerceIn(0, chapters.size - 1) ?: 0
initialLocator?.chapterIndex?.coerceIn(0, max(0, chapters.size - 1)) ?: 0
)
}
@ -1353,7 +1353,8 @@ fun EpubReaderHost(
view = view,
showBars = showBars,
initialIsAppearanceLightStatusBars = initialIsAppearanceLightStatusBars,
initialSystemBarsBehavior = initialSystemBarsBehavior
initialSystemBarsBehavior = initialSystemBarsBehavior,
isDarkTheme = isDarkTheme
)
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
@ -2249,7 +2250,7 @@ fun EpubReaderHost(
containerFocusRequester.requestFocus()
}
if (System.currentTimeMillis() - lastScrollHideTime < 250) {
if (System.currentTimeMillis() - lastScrollHideTime < 400) {
Timber.d("Ignoring tap toggle because bars were just hidden by scroll (sloppy tap).")
} else {
if (showBars || showFormatAdjustmentBars) {

View file

@ -23,7 +23,6 @@ import timber.log.Timber
import android.view.KeyEvent
import android.view.View
import android.view.Window
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -42,11 +41,9 @@ fun EpubReaderSystemUiController(
view: View,
showBars: Boolean,
initialIsAppearanceLightStatusBars: Boolean,
initialSystemBarsBehavior: Int
initialSystemBarsBehavior: Int,
isDarkTheme: Boolean
) {
val isDarkTheme = isSystemInDarkTheme()
// 1. Handle Immersive Mode (Enter/Exit)
DisposableEffect(window, view, initialIsAppearanceLightStatusBars, initialSystemBarsBehavior) {
if (window == null) {
Timber.w("Window is null, cannot control system UI.")
@ -68,7 +65,6 @@ fun EpubReaderSystemUiController(
}
}
// 2. Handle Status Bar Appearance (Dark/Light theme)
LaunchedEffect(window, view, isDarkTheme) {
if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view)
@ -76,7 +72,6 @@ fun EpubReaderSystemUiController(
}
}
// 3. Handle Show/Hide Bars dynamically
LaunchedEffect(showBars, window, view) {
if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view)

View file

@ -270,19 +270,21 @@ internal object PdfBitmapPool {
private val pool = ConcurrentLinkedQueue<Bitmap>()
private const val MAX_POOL_SIZE = 4
fun get(size: Int): Bitmap {
fun get(width: Int, height: Int): Bitmap {
val iterator = pool.iterator()
while (iterator.hasNext()) {
val b = iterator.next()
if (b.width == size && b.height == size && !b.isRecycled) {
if (b.width == width && b.height == height && !b.isRecycled) {
iterator.remove()
b.eraseColor(AndroidColor.TRANSPARENT)
return b
}
}
return createBitmap(size, size)
return createBitmap(width, height)
}
fun get(size: Int): Bitmap = get(size, size)
fun recycle(bitmap: Bitmap) {
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
pool.offer(bitmap)
@ -1063,7 +1065,8 @@ internal fun PdfPageComposable(
isScrolling,
virtualPage
) {
if (effectiveScale <= 1f) {
val needsTiling = effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000
if (!needsTiling) {
if (tiles.isNotEmpty()) {
val oldTiles = tiles
tiles = emptyList()
@ -3303,19 +3306,17 @@ internal fun PdfPageComposable(
actualBitmapHeightPx = scaledHeight
currentPageRotation = 0
val bitmap = PdfBitmapPool.get(maxOf(scaledWidth, scaledHeight))
val MAX_BASE_DIMEN = 3000
var baseW = scaledWidth
var baseH = scaledHeight
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
}
bitmap.eraseColor(android.graphics.Color.WHITE)
val finalBitmap =
if (bitmap.width != scaledWidth || bitmap.height != scaledHeight) {
val scaled = bitmap.scale(scaledWidth, scaledHeight, false)
if (scaled !== bitmap) PdfBitmapPool.recycle(bitmap)
scaled.eraseColor(android.graphics.Color.WHITE)
scaled
} else {
bitmap
}
val finalBitmap = PdfBitmapPool.get(baseW, baseH)
finalBitmap.eraseColor(android.graphics.Color.WHITE)
val old = bitmapState
if (old != null && old !== finalBitmap) {
@ -3324,10 +3325,6 @@ internal fun PdfPageComposable(
}
}
bitmapState = finalBitmap
if (old != null && old !== finalBitmap) {
if (old !== PdfThumbnailCache.get(pageIndex)) old.recycle()
}
currentRenderedPageId = targetPageId
isLoadingPage = false
@ -3375,15 +3372,24 @@ internal fun PdfPageComposable(
return@withContext null
}
val MAX_BASE_DIMEN = 3000
var baseW = scaledWidth
var baseH = scaledHeight
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
baseH = (baseH * downScale).toInt().coerceAtLeast(1)
}
Timber.d(
"Rendering page $pageIndex at ${scaledWidth}x${scaledHeight}"
"Rendering page $pageIndex at ${baseW}x${baseH} (logical: ${scaledWidth}x${scaledHeight})"
)
val newBitmap = createBitmap(scaledWidth, scaledHeight)
val newBitmap = createBitmap(baseW, baseH)
localBitmap = newBitmap
page.renderPageBitmap(
newBitmap,
0, 0,
scaledWidth, scaledHeight,
baseW, baseH,
true
)
page.close()
@ -3892,7 +3898,8 @@ private fun PdfBitmapLayer(
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
if (effectiveScale > 1f) {
val needsTiling = effectiveScale > 1f || targetWidth > 3000 || targetHeight > 3000
if (needsTiling) {
tiles.forEach { tile ->
if (!tile.bitmap.isRecycled) {
drawImage(

View file

@ -3215,8 +3215,11 @@ fun PdfViewerScreen(
Timber.i("PDF document loaded optimistically. Total Pages: $totalPages.")
}
} catch (e: Exception) {
if (e.javaClass.name.contains("PasswordException") || e.cause?.javaClass?.name?.contains("PasswordException") == true) {
} catch (e: Throwable) {
val errorString = e.toString()
val causeString = e.cause?.toString() ?: ""
if (errorString.contains("PasswordException") || causeString.contains("PasswordException")) {
Timber.w("PDF is password protected or password incorrect.")
withContext(Dispatchers.Main) {
if (documentPassword != null) {

View file

@ -154,12 +154,12 @@ class BaseTtsSynthesizer(private val context: Context) {
val targetVoice = availableVoices.find { it.name == preferredVoiceName }
if (targetVoice != null) {
Timber.d("BaseTts: Setting preferred voice to ${targetVoice.name} (${targetVoice.locale})")
tts?.voice = targetVoice
try {
tts?.language = targetVoice.locale
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to set language for voice")
}
tts?.voice = targetVoice
} else {
Timber.w("BaseTts: Preferred voice '$preferredVoiceName' not found in current engine.")
}